Compare commits
10 Commits
b6aa7da1bf
...
17fe5cecf0
| Author | SHA1 | Date | |
|---|---|---|---|
| 17fe5cecf0 | |||
| 8987c50d49 | |||
| 8d19ff39a7 | |||
| 683050f271 | |||
| 98e3633d31 | |||
| bc1f7cce88 | |||
| 364f53fb21 | |||
| af44db6e5d | |||
| 3dc13949eb | |||
| 58669a0abc |
@ -15,7 +15,7 @@ A real-time karaoke application designed for in-home party use with multi-user s
|
||||
- **Artist Browsing** - Browse songs by artist with modal views
|
||||
- **Song Lists** - Predefined song collections with availability matching
|
||||
- **Top Played** - Popular songs based on play history
|
||||
- **New Songs** - Recently added songs to the catalog
|
||||
- **New Songs** - Recently added songs to the catalog (see PRD for implementation details)
|
||||
|
||||
### **Admin Features**
|
||||
- **Queue Control** - Reorder and delete queue items
|
||||
|
||||
13
docs/PRD.md
13
docs/PRD.md
@ -148,6 +148,19 @@ This document defines the functional, technical, and UX requirements for the Kar
|
||||
**Requirements (Platform-Agnostic):**
|
||||
- Shows recently added songs from the `newSongs` node
|
||||
- Real-time updates and infinite scroll
|
||||
- **Data Format Support**: Handles both full song objects and path-only references
|
||||
- **Reverse Lookup**: Automatically resolves song paths to full song objects from main catalog
|
||||
- **Backward Compatibility**: Works with both old and new data formats
|
||||
|
||||
**Implementation Details:**
|
||||
- **Format Detection**: Automatically detects whether `newSongs` contains full song objects or path-only references
|
||||
- **Reverse Lookup**: When path-only data is detected, performs lookup against main songs catalog
|
||||
- **Error Resilience**: Gracefully handles missing songs without breaking the UI
|
||||
- **Debug Logging**: Provides detailed logging for troubleshooting missing songs
|
||||
|
||||
**Data Formats Supported:**
|
||||
1. **New Format**: `{ path: "song_path", key: "firebase_key" }` → Performs reverse lookup
|
||||
2. **Old Format**: Full song objects with `artist`, `title`, `path`, etc. → Uses as-is
|
||||
|
||||
**Platform Implementation:**
|
||||
- **Web:** See `platforms/web/PRD-web.md#new-songs` for React/Ionic implementation
|
||||
|
||||
194
docs/README.md
194
docs/README.md
@ -1,194 +0,0 @@
|
||||
# 📄 Documentation & Reference Models
|
||||
|
||||
This `/docs` folder contains **Product Requirements Documents (PRD)**, **data model definitions**, and **Firebase schema references** for the Karaoke App project.
|
||||
|
||||
These files are intended for:
|
||||
- 📃 Developers reviewing the business logic and architecture.
|
||||
- 🤖 AI tools like Cursor or Copilot that reference documentation for context-aware coding.
|
||||
- 📝 Project planning, architecture decisions, and future enhancements.
|
||||
- 🚀 **Building the app from scratch with any framework/platform combination.**
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `PRD.md` | **Complete Product Requirements Document** — platform-agnostic business logic, technical specifications, data flows, service APIs, component architecture, error handling, and performance optimizations. **Self-guiding for AI implementation.** |
|
||||
| `types.ts` | Reference TypeScript interfaces used for modeling app objects. **Not imported into app runtime code.** |
|
||||
| `firebase_schema.json` | Example Firebase Realtime Database structure for understanding data relationships and CRUD operations. |
|
||||
| `platforms/web/design/` | **Web UI/UX Design Assets** — mockups and visual references for web platform features. |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 How to Use This Documentation
|
||||
|
||||
### **For AI-Assisted Development:**
|
||||
Simply say **"Read this PRD"** in any new chat. The PRD contains:
|
||||
- **Self-guiding instructions** for AI implementation
|
||||
- **Complete technical specifications** for 100% accuracy
|
||||
- **Implementation questions** to determine platform/framework choices
|
||||
- **Step-by-step build process** with checklists
|
||||
|
||||
### **For Human Developers:**
|
||||
- **Reference during implementation** for business logic and data flows
|
||||
- **Guide for architecture decisions** and technology choices
|
||||
- **Source of truth** for all functional requirements
|
||||
- **Migration guide** when switching frameworks/platforms
|
||||
|
||||
### **For New Implementations:**
|
||||
1. **Read the PRD completely** - it contains comprehensive specifications
|
||||
2. **Review design assets** in `platforms/web/design/` folder for visual reference
|
||||
3. **Answer implementation questions** from Section 29
|
||||
4. **Follow the implementation checklist** for complete build process
|
||||
5. **Preserve business logic** while adapting UI to chosen framework
|
||||
|
||||
---
|
||||
|
||||
## 📋 Key Features of the Updated PRD
|
||||
|
||||
### **Platform-Agnostic Design:**
|
||||
- **Core business logic** completely separated from implementation details
|
||||
- **Firebase architecture** and data models for any platform
|
||||
- **Cross-platform references** to platform-specific PRDs
|
||||
- **Universal business rules** that apply to all implementations
|
||||
|
||||
### **Complete Technical Specifications:**
|
||||
- **Firebase data structure** and relationships
|
||||
- **Business logic patterns** and validation rules
|
||||
- **Real-time synchronization** requirements
|
||||
- **User role permissions** and access control
|
||||
- **Error handling scenarios** and recovery patterns
|
||||
- **Performance requirements** and optimization guidelines
|
||||
|
||||
### **Implementation Guide:**
|
||||
- **Platform selection questions** to determine technology stack
|
||||
- **5-phase implementation checklist** for complete builds
|
||||
- **Business logic preservation** guidelines
|
||||
- **Critical success factors** for accurate implementations
|
||||
|
||||
### **Design Assets:**
|
||||
- **Visual mockups** for all major features and screens
|
||||
- **Platform-specific designs** (web, mobile, etc.)
|
||||
- **UX patterns** and interaction flows
|
||||
- **Design system references** for consistent implementation
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Workflow for New Versions
|
||||
|
||||
### **When Creating New Implementations:**
|
||||
1. **Use the PRD as-is** - it's designed for any framework/platform
|
||||
2. **Reference design assets** for visual guidance and UX patterns
|
||||
3. **Follow the implementation guide** in Section 29
|
||||
4. **Preserve all business logic** while adapting UI layer
|
||||
5. **Test against specifications** for 100% accuracy
|
||||
|
||||
### **When Updating the PRD:**
|
||||
1. **Keep platform-agnostic requirements** intact
|
||||
2. **Add new implementation details** to appropriate sections
|
||||
3. **Update toolset sections** with new technology choices
|
||||
4. **Maintain self-guiding nature** for AI implementation
|
||||
|
||||
### **When Migrating Platforms:**
|
||||
1. **Keep all business logic** from core requirements
|
||||
2. **Replace only implementation details** in platform-specific sections
|
||||
3. **Add new platform sections** following the established pattern
|
||||
4. **Update toolset rationale** for new technology choices
|
||||
|
||||
---
|
||||
|
||||
## Important Notes
|
||||
- ✅ These files are **not intended for direct import or use in application runtime**.
|
||||
- ✅ Validation logic and data models here serve as **development references only**.
|
||||
- ✅ Any updates to business logic, data flow, or app architecture should be reflected here for documentation purposes.
|
||||
- ✅ AI tools may use this information to assist with code generation but will not access `/src` directly.
|
||||
- ✅ **The PRD is self-guiding** - it contains all instructions needed for AI implementation.
|
||||
- ✅ **100% accuracy achievable** when following the complete specifications.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Design Assets Reference
|
||||
|
||||
### **Current Web Design Assets:**
|
||||
Located in `platforms/web/design/` folder with comprehensive mockups for all features:
|
||||
|
||||
#### **Core Navigation & Layout:**
|
||||
- `00-web-layout.JPG` - Overall web layout structure
|
||||
- `01-Login.png` - Login screen design
|
||||
- `02-menu.jpeg`, `02a-menu.jpeg`, `02b-menu.png`, `02c-menu.jpeg` - Navigation menu variations
|
||||
|
||||
#### **Queue Management:**
|
||||
- `02-queue.png` - Main queue view
|
||||
- `02-queue-delete.png` - Queue with delete functionality
|
||||
- `02-queue-drag.png` - Queue reordering with drag and drop
|
||||
- `02-queue-sorting.png` - Queue sorting interface
|
||||
|
||||
#### **Search & Discovery:**
|
||||
- `04-search.png` - Main search interface
|
||||
- `04-search typing .png` - Search with typing interaction
|
||||
- `04-search-song info.png` - Search results with song information
|
||||
|
||||
#### **User Management:**
|
||||
- `05-singers.png` - Singer list view
|
||||
- `05-singers add.png` - Singer management interface
|
||||
|
||||
#### **Content Browsing:**
|
||||
- `06-artists .png` - Artist browse interface
|
||||
- `06-artists (not admin).png` - Non-admin artist view
|
||||
- `06-artists search.png` - Artist search functionality
|
||||
- `06-artists songs.png` - Artist songs list
|
||||
|
||||
#### **User Features:**
|
||||
- `07-favorites.png` - Favorites management
|
||||
- `08-history.png` - Play history view
|
||||
- `09-songs list.png` - Main song lists view
|
||||
- `09-song lists - songs.png` - Song lists with song details
|
||||
- `09- song lists songs expand.png` - Song lists with expandable sections
|
||||
|
||||
#### **Admin Features:**
|
||||
- `10-Settings.png` - Settings interface
|
||||
- `11-top 100.png` - Top played songs
|
||||
- `12-favorites .png` - Favorites view
|
||||
- `12-favorite lists.png` - Favorite lists management
|
||||
|
||||
#### **Menu States:**
|
||||
- `03-menu.png` - General menu layout
|
||||
- `03-menu current page and non-admin.png` - Navigation with current page indicators
|
||||
- `03-menu playing (admin).png` - Admin view during playback
|
||||
|
||||
### **Future Platform Design Structure:**
|
||||
```
|
||||
docs/platforms/
|
||||
├── web/
|
||||
│ └── design/ # Current web mockups
|
||||
├── ios/
|
||||
│ └── design/ # Future iOS designs
|
||||
├── android/
|
||||
│ └── design/ # Future Android designs
|
||||
├── tablet/
|
||||
│ └── design/ # Future tablet designs
|
||||
└── desktop/
|
||||
└── design/ # Future desktop designs
|
||||
```
|
||||
|
||||
### **Using Design Assets:**
|
||||
- **Reference during implementation** for visual accuracy
|
||||
- **Understand UX patterns** and interaction flows
|
||||
- **Guide UI component design** and layout decisions
|
||||
- **Ensure consistency** across different implementations
|
||||
- **Validate feature completeness** against visual requirements
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Success Metrics
|
||||
- **Zero ambiguity** in technical requirements
|
||||
- **Framework independence** for easy migration
|
||||
- **Complete implementation path** from start to finish
|
||||
- **Consistent results** across different technology stacks
|
||||
- **Self-documenting** for future developers and AI tools
|
||||
|
||||
---
|
||||
|
||||
_This documentation is designed to be your **ultimate development tool** - enabling accurate builds with any framework/platform combination._
|
||||
|
||||
4
functions/execute-samples.txt
Normal file
4
functions/execute-samples.txt
Normal file
@ -0,0 +1,4 @@
|
||||
Invoke-WebRequest -Uri "https://us-central1-firebase-herse.cloudfunctions.net/recalculateTopPlayed" `
|
||||
>> -Method POST `
|
||||
>> -Headers @{"Content-Type"="application/json"} `
|
||||
>> -Body '{"data": {"controllerName": "bsully5150"}}'
|
||||
@ -27,25 +27,20 @@ exports.updateTopPlayedOnHistoryChange = functions.database
|
||||
console.log('No history data found, skipping TopPlayed update');
|
||||
return;
|
||||
}
|
||||
// Aggregate history items by artist + title combination
|
||||
// Aggregate history items by path
|
||||
const aggregation = {};
|
||||
Object.values(historyData).forEach((song) => {
|
||||
const historySong = song;
|
||||
if (historySong && historySong.artist && historySong.title) {
|
||||
// Create a unique key based on artist and title (case-insensitive)
|
||||
// Replace invalid Firebase key characters with underscores
|
||||
const sanitizedArtist = String(historySong.artist || '').toLowerCase().trim().replace(/[.#$/[\]]/g, '_');
|
||||
const sanitizedTitle = String(historySong.title || '').toLowerCase().trim().replace(/[.#$/[\]]/g, '_');
|
||||
const key = `${sanitizedArtist}_${sanitizedTitle}`;
|
||||
if (aggregation[key]) {
|
||||
// Increment count for existing song
|
||||
aggregation[key].count += historySong.count || 1;
|
||||
if (historySong && historySong.path) {
|
||||
const path = historySong.path;
|
||||
if (aggregation[path]) {
|
||||
aggregation[path].count += historySong.count || 1;
|
||||
}
|
||||
else {
|
||||
// Create new entry
|
||||
aggregation[key] = {
|
||||
aggregation[path] = {
|
||||
artist: historySong.artist,
|
||||
title: historySong.title,
|
||||
path: historySong.path,
|
||||
count: historySong.count || 1
|
||||
};
|
||||
}
|
||||
@ -53,26 +48,17 @@ exports.updateTopPlayedOnHistoryChange = functions.database
|
||||
});
|
||||
// Convert aggregation to array, sort by count (descending), and take top 100
|
||||
const sortedSongs = Object.entries(aggregation)
|
||||
.map(([key, songData]) => ({
|
||||
key,
|
||||
.map(([, songData]) => ({
|
||||
artist: songData.artist,
|
||||
title: songData.title,
|
||||
path: songData.path,
|
||||
count: songData.count
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count) // Sort by count descending
|
||||
.slice(0, 100); // Take only top 100
|
||||
// Convert back to object format for Firebase
|
||||
const topPlayedData = {};
|
||||
sortedSongs.forEach((song) => {
|
||||
topPlayedData[song.key] = {
|
||||
artist: song.artist,
|
||||
title: song.title,
|
||||
count: song.count
|
||||
};
|
||||
});
|
||||
// Update the topPlayed collection
|
||||
await controllerRef.child('topPlayed').set(topPlayedData);
|
||||
console.log(`Successfully updated TopPlayed for controller ${controllerName} with ${Object.keys(topPlayedData).length} unique songs`);
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 100);
|
||||
// Write as an array so Firebase uses numeric keys
|
||||
await controllerRef.child('topPlayed').set(sortedSongs);
|
||||
console.log(`Successfully updated TopPlayed for controller ${controllerName} with ${sortedSongs.length} unique songs (by path)`);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error updating TopPlayed:', error);
|
||||
@ -83,7 +69,7 @@ exports.updateTopPlayedOnHistoryChange = functions.database
|
||||
* Alternative function that can be called manually to recalculate TopPlayed
|
||||
* This is useful for initial setup or data migration
|
||||
*/
|
||||
exports.recalculateTopPlayed = functions.https.onCall(async (data, context) => {
|
||||
exports.recalculateTopPlayed = functions.https.onCall(async (data) => {
|
||||
const { controllerName } = data;
|
||||
if (!controllerName) {
|
||||
throw new functions.https.HttpsError('invalid-argument', 'controllerName is required');
|
||||
@ -100,25 +86,20 @@ exports.recalculateTopPlayed = functions.https.onCall(async (data, context) => {
|
||||
await controllerRef.child('topPlayed').set({});
|
||||
return { success: true, message: 'No history data found, TopPlayed cleared' };
|
||||
}
|
||||
// Aggregate history items by artist + title combination
|
||||
// Aggregate history items by path
|
||||
const aggregation = {};
|
||||
Object.values(historyData).forEach((song) => {
|
||||
const historySong = song;
|
||||
if (historySong && historySong.artist && historySong.title) {
|
||||
// Create a unique key based on artist and title (case-insensitive)
|
||||
// Replace invalid Firebase key characters with underscores
|
||||
const sanitizedArtist = String(historySong.artist || '').toLowerCase().trim().replace(/[.#$/[\]]/g, '_');
|
||||
const sanitizedTitle = String(historySong.title || '').toLowerCase().trim().replace(/[.#$/[\]]/g, '_');
|
||||
const key = `${sanitizedArtist}_${sanitizedTitle}`;
|
||||
if (aggregation[key]) {
|
||||
// Increment count for existing song
|
||||
aggregation[key].count += historySong.count || 1;
|
||||
if (historySong && historySong.path) {
|
||||
const path = historySong.path;
|
||||
if (aggregation[path]) {
|
||||
aggregation[path].count += historySong.count || 1;
|
||||
}
|
||||
else {
|
||||
// Create new entry
|
||||
aggregation[key] = {
|
||||
aggregation[path] = {
|
||||
artist: historySong.artist,
|
||||
title: historySong.title,
|
||||
path: historySong.path,
|
||||
count: historySong.count || 1
|
||||
};
|
||||
}
|
||||
@ -126,30 +107,21 @@ exports.recalculateTopPlayed = functions.https.onCall(async (data, context) => {
|
||||
});
|
||||
// Convert aggregation to array, sort by count (descending), and take top 100
|
||||
const sortedSongs = Object.entries(aggregation)
|
||||
.map(([key, songData]) => ({
|
||||
key,
|
||||
.map(([, songData]) => ({
|
||||
artist: songData.artist,
|
||||
title: songData.title,
|
||||
path: songData.path,
|
||||
count: songData.count
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count) // Sort by count descending
|
||||
.slice(0, 100); // Take only top 100
|
||||
// Convert back to object format for Firebase
|
||||
const topPlayedData = {};
|
||||
sortedSongs.forEach((song) => {
|
||||
topPlayedData[song.key] = {
|
||||
artist: song.artist,
|
||||
title: song.title,
|
||||
count: song.count
|
||||
};
|
||||
});
|
||||
// Update the topPlayed collection
|
||||
await controllerRef.child('topPlayed').set(topPlayedData);
|
||||
console.log(`Successfully recalculated TopPlayed for controller ${controllerName} with ${Object.keys(topPlayedData).length} unique songs`);
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 100);
|
||||
// Write as an array so Firebase uses numeric keys
|
||||
await controllerRef.child('topPlayed').set(sortedSongs);
|
||||
console.log(`Successfully recalculated TopPlayed for controller ${controllerName} with ${sortedSongs.length} unique songs (by path)`);
|
||||
return {
|
||||
success: true,
|
||||
message: `TopPlayed recalculated successfully`,
|
||||
songCount: Object.keys(topPlayedData).length
|
||||
songCount: sortedSongs.length
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -19,17 +19,11 @@ interface HistorySong {
|
||||
key?: string;
|
||||
}
|
||||
|
||||
interface TopPlayed {
|
||||
artist: string;
|
||||
title: string;
|
||||
count: number;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
interface HistoryAggregation {
|
||||
[key: string]: {
|
||||
interface HistoryAggregationByPath {
|
||||
[path: string]: {
|
||||
artist: string;
|
||||
title: string;
|
||||
path: string;
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
@ -59,26 +53,20 @@ export const updateTopPlayedOnHistoryChange = functions.database
|
||||
return;
|
||||
}
|
||||
|
||||
// Aggregate history items by artist + title combination
|
||||
const aggregation: HistoryAggregation = {};
|
||||
// Aggregate history items by path
|
||||
const aggregation: HistoryAggregationByPath = {};
|
||||
|
||||
Object.values(historyData).forEach((song: unknown) => {
|
||||
const historySong = song as HistorySong;
|
||||
if (historySong && historySong.artist && historySong.title) {
|
||||
// Create a unique key based on artist and title (case-insensitive)
|
||||
// Replace invalid Firebase key characters with underscores
|
||||
const sanitizedArtist = String(historySong.artist || '').toLowerCase().trim().replace(/[.#$/[\]]/g, '_');
|
||||
const sanitizedTitle = String(historySong.title || '').toLowerCase().trim().replace(/[.#$/[\]]/g, '_');
|
||||
const key = `${sanitizedArtist}_${sanitizedTitle}`;
|
||||
|
||||
if (aggregation[key]) {
|
||||
// Increment count for existing song
|
||||
aggregation[key].count += historySong.count || 1;
|
||||
if (historySong && historySong.path) {
|
||||
const path = historySong.path;
|
||||
if (aggregation[path]) {
|
||||
aggregation[path].count += historySong.count || 1;
|
||||
} else {
|
||||
// Create new entry
|
||||
aggregation[key] = {
|
||||
aggregation[path] = {
|
||||
artist: historySong.artist,
|
||||
title: historySong.title,
|
||||
path: historySong.path,
|
||||
count: historySong.count || 1
|
||||
};
|
||||
}
|
||||
@ -87,30 +75,19 @@ export const updateTopPlayedOnHistoryChange = functions.database
|
||||
|
||||
// Convert aggregation to array, sort by count (descending), and take top 100
|
||||
const sortedSongs = Object.entries(aggregation)
|
||||
.map(([key, songData]) => ({
|
||||
key,
|
||||
.map(([, songData]) => ({
|
||||
artist: songData.artist,
|
||||
title: songData.title,
|
||||
path: songData.path,
|
||||
count: songData.count
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count) // Sort by count descending
|
||||
.slice(0, 100); // Take only top 100
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 100);
|
||||
|
||||
// Convert back to object format for Firebase
|
||||
const topPlayedData: { [key: string]: TopPlayed } = {};
|
||||
// Write as an array so Firebase uses numeric keys
|
||||
await controllerRef.child('topPlayed').set(sortedSongs);
|
||||
|
||||
sortedSongs.forEach((song) => {
|
||||
topPlayedData[song.key] = {
|
||||
artist: song.artist,
|
||||
title: song.title,
|
||||
count: song.count
|
||||
};
|
||||
});
|
||||
|
||||
// Update the topPlayed collection
|
||||
await controllerRef.child('topPlayed').set(topPlayedData);
|
||||
|
||||
console.log(`Successfully updated TopPlayed for controller ${controllerName} with ${Object.keys(topPlayedData).length} unique songs`);
|
||||
console.log(`Successfully updated TopPlayed for controller ${controllerName} with ${sortedSongs.length} unique songs (by path)`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating TopPlayed:', error);
|
||||
@ -122,7 +99,7 @@ export const updateTopPlayedOnHistoryChange = functions.database
|
||||
* Alternative function that can be called manually to recalculate TopPlayed
|
||||
* This is useful for initial setup or data migration
|
||||
*/
|
||||
export const recalculateTopPlayed = functions.https.onCall(async (data, context) => {
|
||||
export const recalculateTopPlayed = functions.https.onCall(async (data) => {
|
||||
const { controllerName } = data;
|
||||
|
||||
if (!controllerName) {
|
||||
@ -145,26 +122,20 @@ export const recalculateTopPlayed = functions.https.onCall(async (data, context)
|
||||
return { success: true, message: 'No history data found, TopPlayed cleared' };
|
||||
}
|
||||
|
||||
// Aggregate history items by artist + title combination
|
||||
const aggregation: HistoryAggregation = {};
|
||||
// Aggregate history items by path
|
||||
const aggregation: HistoryAggregationByPath = {};
|
||||
|
||||
Object.values(historyData).forEach((song: unknown) => {
|
||||
const historySong = song as HistorySong;
|
||||
if (historySong && historySong.artist && historySong.title) {
|
||||
// Create a unique key based on artist and title (case-insensitive)
|
||||
// Replace invalid Firebase key characters with underscores
|
||||
const sanitizedArtist = String(historySong.artist || '').toLowerCase().trim().replace(/[.#$/[\]]/g, '_');
|
||||
const sanitizedTitle = String(historySong.title || '').toLowerCase().trim().replace(/[.#$/[\]]/g, '_');
|
||||
const key = `${sanitizedArtist}_${sanitizedTitle}`;
|
||||
|
||||
if (aggregation[key]) {
|
||||
// Increment count for existing song
|
||||
aggregation[key].count += historySong.count || 1;
|
||||
if (historySong && historySong.path) {
|
||||
const path = historySong.path;
|
||||
if (aggregation[path]) {
|
||||
aggregation[path].count += historySong.count || 1;
|
||||
} else {
|
||||
// Create new entry
|
||||
aggregation[key] = {
|
||||
aggregation[path] = {
|
||||
artist: historySong.artist,
|
||||
title: historySong.title,
|
||||
path: historySong.path,
|
||||
count: historySong.count || 1
|
||||
};
|
||||
}
|
||||
@ -173,35 +144,24 @@ export const recalculateTopPlayed = functions.https.onCall(async (data, context)
|
||||
|
||||
// Convert aggregation to array, sort by count (descending), and take top 100
|
||||
const sortedSongs = Object.entries(aggregation)
|
||||
.map(([key, songData]) => ({
|
||||
key,
|
||||
.map(([, songData]) => ({
|
||||
artist: songData.artist,
|
||||
title: songData.title,
|
||||
path: songData.path,
|
||||
count: songData.count
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count) // Sort by count descending
|
||||
.slice(0, 100); // Take only top 100
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 100);
|
||||
|
||||
// Convert back to object format for Firebase
|
||||
const topPlayedData: { [key: string]: TopPlayed } = {};
|
||||
// Write as an array so Firebase uses numeric keys
|
||||
await controllerRef.child('topPlayed').set(sortedSongs);
|
||||
|
||||
sortedSongs.forEach((song) => {
|
||||
topPlayedData[song.key] = {
|
||||
artist: song.artist,
|
||||
title: song.title,
|
||||
count: song.count
|
||||
};
|
||||
});
|
||||
|
||||
// Update the topPlayed collection
|
||||
await controllerRef.child('topPlayed').set(topPlayedData);
|
||||
|
||||
console.log(`Successfully recalculated TopPlayed for controller ${controllerName} with ${Object.keys(topPlayedData).length} unique songs`);
|
||||
console.log(`Successfully recalculated TopPlayed for controller ${controllerName} with ${sortedSongs.length} unique songs (by path)`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `TopPlayed recalculated successfully`,
|
||||
songCount: Object.keys(topPlayedData).length
|
||||
songCount: sortedSongs.length
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
|
||||
15
package-lock.json
generated
15
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
108
src/App.css
108
src/App.css
@ -74,3 +74,111 @@ ion-accordion ion-item::part(native) {
|
||||
--border-width: 0 0 1px 0 !important;
|
||||
--border-color: var(--ion-item-border-color, rgba(0, 0, 0, 0.13)) !important;
|
||||
}
|
||||
|
||||
/* Top 100 Highlighting */
|
||||
.highlighted-song {
|
||||
border: 3px solid #ff6b35 !important;
|
||||
background: linear-gradient(135deg, rgba(255, 107, 53, 0.1) 0%, rgba(255, 107, 53, 0.05) 100%) !important;
|
||||
border-radius: 12px !important;
|
||||
margin: 8px 0 !important;
|
||||
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.2), 0 2px 4px rgba(0, 0, 0, 0.1) !important;
|
||||
position: relative !important;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.highlighted-song::before {
|
||||
content: "★ MOST PLAYED";
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: 12px;
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 8px rgba(255, 107, 53, 0.4);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Dark mode support for highlighted song */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.highlighted-song {
|
||||
background: linear-gradient(135deg, rgba(255, 107, 53, 0.15) 0%, rgba(255, 107, 53, 0.08) 100%) !important;
|
||||
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.3), 0 2px 4px rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
.highlighted-song::before {
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
|
||||
box-shadow: 0 2px 8px rgba(255, 107, 53, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* Section headers styling */
|
||||
.section-header {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border-left: 4px solid #007bff;
|
||||
padding: 16px;
|
||||
margin: 8px 0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.section-header.other-variations {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border-left: 4px solid #6c757d;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #495057;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 12px !important;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.section-header {
|
||||
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
|
||||
border-left: 4px solid #4299e1;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.section-header.other-variations {
|
||||
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
|
||||
border-left: 4px solid #718096;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced pill styling */
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pill--count {
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
|
||||
color: white;
|
||||
margin-left: 8px;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.pill--count:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(255, 107, 53, 0.3);
|
||||
}
|
||||
|
||||
40
src/components/common/SongCountDisplay.tsx
Normal file
40
src/components/common/SongCountDisplay.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { IonChip } from '@ionic/react';
|
||||
import { getSongCountByArtistTitle } from '../../utils/dataProcessing';
|
||||
import type { Song } from '../../types';
|
||||
|
||||
interface SongCountDisplayProps {
|
||||
songs: Song[];
|
||||
artist: string;
|
||||
title: string;
|
||||
showLabel?: boolean;
|
||||
color?: 'primary' | 'secondary' | 'tertiary' | 'success' | 'warning' | 'danger' | 'medium' | 'light' | 'dark';
|
||||
}
|
||||
|
||||
export const SongCountDisplay: React.FC<SongCountDisplayProps> = ({
|
||||
songs,
|
||||
artist,
|
||||
title,
|
||||
showLabel = true,
|
||||
color = 'primary'
|
||||
}) => {
|
||||
const count = getSongCountByArtistTitle(songs, artist, title);
|
||||
|
||||
if (count === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = showLabel
|
||||
? `${count} version${count !== 1 ? 's' : ''}`
|
||||
: count.toString();
|
||||
|
||||
return (
|
||||
<IonChip
|
||||
color={color}
|
||||
>
|
||||
{label}
|
||||
</IonChip>
|
||||
);
|
||||
};
|
||||
|
||||
export default SongCountDisplay;
|
||||
@ -1,8 +1,8 @@
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import { IonItem, IonLabel } from '@ionic/react';
|
||||
import { IonItem, IonLabel, IonIcon } from '@ionic/react';
|
||||
import ActionButton from './ActionButton';
|
||||
import { useAppSelector } from '../../redux';
|
||||
import { selectQueue, selectFavorites, selectCurrentSinger } from '../../redux';
|
||||
import { selectQueue, selectFavorites, selectCurrentSinger, selectTopPlayedArray } from '../../redux';
|
||||
import { useActions } from '../../hooks/useActions';
|
||||
import { useModal } from '../../hooks/useModalContext';
|
||||
import { debugLog } from '../../utils/logger';
|
||||
@ -10,6 +10,7 @@ import type { SongItemProps, QueueItem, Song } from '../../types';
|
||||
import { SongItemContext } from '../../types';
|
||||
import { ActionButtonVariant, ActionButtonSize, ActionButtonIconSlot } from '../../types';
|
||||
import { Icons } from '../../constants';
|
||||
import { star } from 'ionicons/icons';
|
||||
|
||||
// Utility function to extract filename from path
|
||||
const extractFilename = (path: string): string => {
|
||||
@ -29,11 +30,13 @@ export const SongInfoDisplay: React.FC<{
|
||||
showPath?: boolean;
|
||||
showCount?: boolean;
|
||||
showFullPath?: boolean;
|
||||
showTopPlayedStar?: boolean;
|
||||
}> = React.memo(({
|
||||
song,
|
||||
showPath = false,
|
||||
showCount = false,
|
||||
showFullPath = false
|
||||
showFullPath = false,
|
||||
showTopPlayedStar = false
|
||||
}) => {
|
||||
return (
|
||||
<IonLabel>
|
||||
@ -43,9 +46,19 @@ export const SongInfoDisplay: React.FC<{
|
||||
fontWeight: 'bold',
|
||||
fontSize: '1rem',
|
||||
color: 'var(--ion-color-dark)',
|
||||
marginBottom: '4px'
|
||||
marginBottom: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
{showTopPlayedStar && (
|
||||
<IonIcon
|
||||
icon={star}
|
||||
color="warning"
|
||||
style={{ fontSize: '1rem' }}
|
||||
/>
|
||||
)}
|
||||
{song.title}
|
||||
</div>
|
||||
<div
|
||||
@ -210,6 +223,7 @@ const SongItem: React.FC<SongItemProps> = React.memo(({
|
||||
const queue = useAppSelector(selectQueue);
|
||||
const favorites = useAppSelector(selectFavorites);
|
||||
const currentSingerName = useAppSelector(selectCurrentSinger);
|
||||
const topPlayedArray = useAppSelector(selectTopPlayedArray);
|
||||
|
||||
// Get unified action handlers
|
||||
const { handleAddToQueue, handleToggleFavorite, handleRemoveFromQueue } = useActions();
|
||||
@ -226,6 +240,11 @@ const SongItem: React.FC<SongItemProps> = React.memo(({
|
||||
[favorites, song.path]
|
||||
);
|
||||
|
||||
const isInTopPlayed = useMemo(() =>
|
||||
(Object.values(topPlayedArray) as Song[]).some(topSong => topSong.path === song.path),
|
||||
[topPlayedArray, song.path]
|
||||
);
|
||||
|
||||
// Find queue item key for removal (only needed for queue context)
|
||||
const queueItemKey = useMemo(() =>
|
||||
context === SongItemContext.QUEUE
|
||||
@ -292,6 +311,7 @@ const SongItem: React.FC<SongItemProps> = React.memo(({
|
||||
showPath={shouldShowPath}
|
||||
showCount={shouldShowCount}
|
||||
showFullPath={showFullPath}
|
||||
showTopPlayedStar={isInTopPlayed}
|
||||
/>
|
||||
|
||||
{showActions && (
|
||||
|
||||
@ -11,4 +11,5 @@ export { TwoLineDisplay } from './TwoLineDisplay';
|
||||
export { default as ListItem } from './ListItem';
|
||||
export { NumberDisplay } from './NumberDisplay';
|
||||
export { ModalHeader } from './ModalHeader';
|
||||
export { default as VirtualizedList } from './VirtualizedList';
|
||||
export { default as VirtualizedList } from './VirtualizedList';
|
||||
export { default as SongCountDisplay } from './SongCountDisplay';
|
||||
@ -33,7 +33,7 @@ export const UI_CONSTANTS = {
|
||||
MAX_ITEMS: 100,
|
||||
},
|
||||
HISTORY: {
|
||||
MAX_ITEMS: 50,
|
||||
MAX_ITEMS: 250, // Increased from 50 to show more history items
|
||||
},
|
||||
TOP_PLAYED: {
|
||||
MAX_ITEMS: 20,
|
||||
|
||||
@ -16,6 +16,7 @@ const SongLists: React.FC = () => {
|
||||
hasMore,
|
||||
loadMore,
|
||||
checkSongAvailability,
|
||||
getSongCountForSongListSong,
|
||||
} = useSongLists();
|
||||
|
||||
const songListData = useAppSelector(selectSongList);
|
||||
@ -94,7 +95,8 @@ const SongLists: React.FC = () => {
|
||||
<IonAccordionGroup value={expandedSongKey}>
|
||||
{selectedListWithAvailability?.songs.map((songListSong: SongListSong & { availableSongs: Song[] }, index) => {
|
||||
const availableSongs = songListSong.availableSongs;
|
||||
const isAvailable = availableSongs.length > 0;
|
||||
const songCount = getSongCountForSongListSong(songListSong);
|
||||
const isAvailable = songCount > 0;
|
||||
const songKey = songListSong.key || `${songListSong.title}-${songListSong.position}-${index}`;
|
||||
|
||||
if (isAvailable) {
|
||||
@ -114,7 +116,7 @@ const SongLists: React.FC = () => {
|
||||
onClick={() => handleSongItemClick(songKey)}
|
||||
endContent={
|
||||
<IonChip color="primary">
|
||||
{availableSongs.length} version{availableSongs.length !== 1 ? 's' : ''}
|
||||
{songCount} version{songCount !== 1 ? 's' : ''}
|
||||
</IonChip>
|
||||
}
|
||||
/>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { IonChip, IonModal, IonIcon, IonContent, IonList } from '@ionic/react';
|
||||
import { list } from 'ionicons/icons';
|
||||
import { IonChip, IonModal, IonIcon, IonContent, IonList, IonItem } from '@ionic/react';
|
||||
import { list, star, layers } from 'ionicons/icons';
|
||||
import { useTopPlayed } from '../../hooks';
|
||||
import { useAppSelector } from '../../redux';
|
||||
import { selectTopPlayed, selectSongsArray } from '../../redux';
|
||||
@ -63,6 +63,9 @@ const Top100: React.FC = () => {
|
||||
return filteredSongs;
|
||||
}, [selectedTopPlayed, allSongs]);
|
||||
|
||||
// Separate the most played song from other variations
|
||||
const mostPlayedSong = selectedSongs.find(song => song.path === selectedTopPlayed?.path);
|
||||
const otherVariations = selectedSongs.filter(song => song.path !== selectedTopPlayed?.path);
|
||||
|
||||
|
||||
// Use real Firebase data from the hook
|
||||
@ -101,7 +104,6 @@ const Top100: React.FC = () => {
|
||||
<IonChip color="primary">
|
||||
{item.count} plays
|
||||
</IonChip>
|
||||
<IonIcon icon={list} color="primary" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@ -118,18 +120,64 @@ const Top100: React.FC = () => {
|
||||
<ModalHeader title={selectedTopPlayed?.artist || ''} onClose={handleCloseModal} />
|
||||
|
||||
<IonContent>
|
||||
<IonList>
|
||||
{selectedSongs.map((song) => (
|
||||
<SongItem
|
||||
key={song.key || `${song.title}-${song.artist}`}
|
||||
song={song}
|
||||
context={SongItemContext.SEARCH}
|
||||
showAddButton={true}
|
||||
showInfoButton={true}
|
||||
showFavoriteButton={false}
|
||||
/>
|
||||
))}
|
||||
</IonList>
|
||||
{/* Most Played Section */}
|
||||
{mostPlayedSong && (
|
||||
<>
|
||||
<IonItem className="section-header" lines="none">
|
||||
<h3>
|
||||
<span className="icon-wrapper" style={{ marginRight: '12px', display: 'inline-block' }}>
|
||||
<IonIcon icon={star} color="warning" />
|
||||
</span>
|
||||
Most Played Version
|
||||
</h3>
|
||||
</IonItem>
|
||||
<IonList>
|
||||
<SongItem
|
||||
key={mostPlayedSong.key || `${mostPlayedSong.title}-${mostPlayedSong.artist}`}
|
||||
song={mostPlayedSong}
|
||||
context={SongItemContext.SEARCH}
|
||||
showAddButton={true}
|
||||
showInfoButton={true}
|
||||
showFavoriteButton={false}
|
||||
className="highlighted-song"
|
||||
/>
|
||||
</IonList>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Other Variations Section */}
|
||||
{otherVariations.length > 0 && (
|
||||
<>
|
||||
<IonItem className="section-header other-variations" lines="none">
|
||||
<h3>
|
||||
<span className="icon-wrapper" style={{ marginRight: '12px', display: 'inline-block' }}>
|
||||
<IonIcon icon={layers} color="medium" />
|
||||
</span>
|
||||
Other Variations ({otherVariations.length})
|
||||
</h3>
|
||||
</IonItem>
|
||||
<IonList>
|
||||
{otherVariations.map((song) => (
|
||||
<SongItem
|
||||
key={song.key || `${song.title}-${song.artist}`}
|
||||
song={song}
|
||||
context={SongItemContext.SEARCH}
|
||||
showAddButton={true}
|
||||
showInfoButton={true}
|
||||
showFavoriteButton={false}
|
||||
/>
|
||||
))}
|
||||
</IonList>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* No variations found */}
|
||||
{selectedSongs.length === 0 && (
|
||||
<div className="ion-padding text-center text-gray-500 dark:text-gray-400">
|
||||
<IonIcon icon={list} size="large" color="medium" />
|
||||
<p className="mt-2">No other variations found for this song</p>
|
||||
</div>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonModal>
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,33 +1,66 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useAppSelector, selectSongListArray, selectSongsArray } from '../redux';
|
||||
import { useActions } from './useActions';
|
||||
import { usePaginatedData } from './index';
|
||||
import type { SongListSong } from '../types';
|
||||
import type { SongListSong, Song } from '../types';
|
||||
|
||||
export const useSongLists = () => {
|
||||
const allSongLists = useAppSelector(selectSongListArray);
|
||||
const allSongs = useAppSelector(selectSongsArray);
|
||||
const { handleAddToQueue, handleToggleFavorite } = useActions();
|
||||
|
||||
// Pre-compute songs by artist and title combination for performance
|
||||
const songsByArtistTitle = useMemo(() => {
|
||||
const songsMap = new Map<string, Song[]>();
|
||||
const countsMap = new Map<string, number>();
|
||||
|
||||
allSongs.forEach(song => {
|
||||
const artist = (song.artist || '').toLowerCase();
|
||||
const title = (song.title || '').toLowerCase();
|
||||
const key = `${artist}|${title}`;
|
||||
|
||||
if (!songsMap.has(key)) {
|
||||
songsMap.set(key, []);
|
||||
countsMap.set(key, 0);
|
||||
}
|
||||
songsMap.get(key)!.push(song);
|
||||
countsMap.set(key, countsMap.get(key)! + 1);
|
||||
});
|
||||
|
||||
return { songsMap, countsMap };
|
||||
}, [allSongs]);
|
||||
|
||||
// Use the composable pagination hook
|
||||
const pagination = usePaginatedData(allSongLists, {
|
||||
itemsPerPage: 20 // Default pagination size
|
||||
});
|
||||
|
||||
// Check if a song exists in the catalog
|
||||
// Get songs by artist and title (now using cached data)
|
||||
const getSongsByArtistTitle = useCallback((artist: string, title: string) => {
|
||||
const key = `${(artist || '').toLowerCase()}|${(title || '').toLowerCase()}`;
|
||||
return songsByArtistTitle.songsMap.get(key) || [];
|
||||
}, [songsByArtistTitle.songsMap]);
|
||||
|
||||
// Get song count by artist and title (now using cached data)
|
||||
const getSongCountByArtistTitle = useCallback((artist: string, title: string) => {
|
||||
const key = `${(artist || '').toLowerCase()}|${(title || '').toLowerCase()}`;
|
||||
return songsByArtistTitle.countsMap.get(key) || 0;
|
||||
}, [songsByArtistTitle.countsMap]);
|
||||
|
||||
// Check if a song exists in the catalog (enhanced version)
|
||||
const checkSongAvailability = useCallback((songListSong: SongListSong) => {
|
||||
if (songListSong.foundSongs && songListSong.foundSongs.length > 0) {
|
||||
return songListSong.foundSongs;
|
||||
}
|
||||
|
||||
// Search for songs by artist and title
|
||||
const matchingSongs = allSongs.filter(song =>
|
||||
(song.artist || '').toLowerCase() === (songListSong.artist || '').toLowerCase() &&
|
||||
(song.title || '').toLowerCase() === (songListSong.title || '').toLowerCase()
|
||||
);
|
||||
|
||||
return matchingSongs;
|
||||
}, [allSongs]);
|
||||
// Use the pre-computed data for better performance
|
||||
return getSongsByArtistTitle(songListSong.artist, songListSong.title);
|
||||
}, [getSongsByArtistTitle]);
|
||||
|
||||
// Get song count for a song list song
|
||||
const getSongCountForSongListSong = useCallback((songListSong: SongListSong) => {
|
||||
return getSongCountByArtistTitle(songListSong.artist, songListSong.title);
|
||||
}, [getSongCountByArtistTitle]);
|
||||
|
||||
return {
|
||||
songLists: pagination.items,
|
||||
@ -37,6 +70,9 @@ export const useSongLists = () => {
|
||||
currentPage: pagination.currentPage,
|
||||
totalPages: pagination.totalPages,
|
||||
checkSongAvailability,
|
||||
getSongCountForSongListSong,
|
||||
getSongsByArtistTitle,
|
||||
getSongCountByArtistTitle,
|
||||
handleAddToQueue,
|
||||
handleToggleFavorite,
|
||||
isLoading: pagination.isLoading,
|
||||
|
||||
@ -172,10 +172,8 @@ const queueSlice = createSlice({
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(removeFromQueue.fulfilled, (state, action) => {
|
||||
.addCase(removeFromQueue.fulfilled, (state) => {
|
||||
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 = {};
|
||||
@ -209,10 +207,8 @@ const queueSlice = createSlice({
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(reorderQueueAsync.fulfilled, (state, action) => {
|
||||
.addCase(reorderQueueAsync.fulfilled, (state) => {
|
||||
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 = {};
|
||||
|
||||
@ -108,14 +108,50 @@ export const selectFavoritesArrayWithoutDisabled = createSelector(
|
||||
);
|
||||
|
||||
export const selectNewSongsArray = createSelector(
|
||||
[selectNewSongs],
|
||||
(newSongs) => sortSongsByArtistAndTitle(objectToArray(newSongs))
|
||||
[selectNewSongs, selectSongs],
|
||||
(newSongs, songs) => {
|
||||
// Handle both formats:
|
||||
// 1. Array of objects with only path property (new format)
|
||||
// 2. Array of full song objects (old format)
|
||||
|
||||
const newSongsArray = objectToArray(newSongs);
|
||||
|
||||
// Check if the first item has only path and key properties (new format) or is a full song object (old format)
|
||||
if (newSongsArray.length > 0 &&
|
||||
typeof newSongsArray[0] === 'object' &&
|
||||
newSongsArray[0] !== null &&
|
||||
'path' in newSongsArray[0] &&
|
||||
!('artist' in newSongsArray[0]) &&
|
||||
!('title' in newSongsArray[0])) {
|
||||
// New format: array of objects with only path property - do reverse lookup
|
||||
const songPaths = (newSongsArray as { path: string }[]).map(item => item.path);
|
||||
const songsMap = new Map(Object.values(songs as Record<string, Song>).map(song => [song.path, song]));
|
||||
|
||||
const resolvedSongs = songPaths
|
||||
.map(path => songsMap.get(path))
|
||||
.filter((song): song is Song => song !== undefined);
|
||||
|
||||
debugLog('selectNewSongsArray - reverse lookup:', {
|
||||
pathsCount: songPaths.length,
|
||||
resolvedCount: resolvedSongs.length,
|
||||
missingPaths: songPaths.filter(path => !songsMap.has(path))
|
||||
});
|
||||
|
||||
return sortSongsByArtistAndTitle(resolvedSongs);
|
||||
} else {
|
||||
// Old format: array of full song objects
|
||||
return sortSongsByArtistAndTitle(newSongsArray as Song[]);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// New songs array without disabled songs
|
||||
export const selectNewSongsArrayWithoutDisabled = createSelector(
|
||||
[selectNewSongsArray, (_state: RootState, disabledSongPaths: Set<string>) => disabledSongPaths],
|
||||
(newSongs, disabledSongPaths) => newSongs.filter(song => !disabledSongPaths.has(song.path))
|
||||
(newSongs, disabledSongPaths) => {
|
||||
// The selectNewSongsArray already handles the reverse lookup, so we just filter disabled songs
|
||||
return newSongs.filter(song => !disabledSongPaths.has(song.path));
|
||||
}
|
||||
);
|
||||
|
||||
export const selectSingersArray = createSelector(
|
||||
|
||||
@ -84,6 +84,7 @@ export interface SongListSong extends Keyable {
|
||||
export interface TopPlayed extends Keyable {
|
||||
artist: string;
|
||||
title: string;
|
||||
path: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
@ -92,7 +93,7 @@ export interface Controller {
|
||||
favorites: Record<string, Song>;
|
||||
history: Record<string, Song>;
|
||||
topPlayed: Record<string, TopPlayed>;
|
||||
newSongs: Record<string, Song>;
|
||||
newSongs: Record<string, Song | { path: string }>; // Can be either Song objects or objects with only path property
|
||||
player: {
|
||||
queue: Record<string, QueueItem>;
|
||||
settings: Settings;
|
||||
|
||||
@ -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;
|
||||
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, number[]>(); // 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, 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
|
||||
@ -121,4 +372,41 @@ export const getQueueStats = (queue: Record<string, QueueItem>) => {
|
||||
singers: [...new Set(queueArray.map(item => item.singer.name))],
|
||||
estimatedDuration: queueArray.length * 3, // Rough estimate: 3 minutes per song
|
||||
};
|
||||
};
|
||||
|
||||
// Get songs by artist and title combination
|
||||
export const getSongsByArtistTitle = (songs: Song[], artist: string, title: string): Song[] => {
|
||||
const artistLower = (artist || '').toLowerCase();
|
||||
const titleLower = (title || '').toLowerCase();
|
||||
|
||||
return songs.filter(song =>
|
||||
(song.artist || '').toLowerCase() === artistLower &&
|
||||
(song.title || '').toLowerCase() === titleLower
|
||||
);
|
||||
};
|
||||
|
||||
// Get song count by artist and title combination
|
||||
export const getSongCountByArtistTitle = (songs: Song[], artist: string, title: string): number => {
|
||||
return getSongsByArtistTitle(songs, artist, title).length;
|
||||
};
|
||||
|
||||
// Create a map of song counts by artist and title for performance
|
||||
export const createSongCountMapByArtistTitle = (songs: Song[]): Map<string, number> => {
|
||||
const countsMap = new Map<string, number>();
|
||||
|
||||
songs.forEach(song => {
|
||||
const artist = (song.artist || '').toLowerCase();
|
||||
const title = (song.title || '').toLowerCase();
|
||||
const key = `${artist}|${title}`;
|
||||
|
||||
countsMap.set(key, (countsMap.get(key) || 0) + 1);
|
||||
});
|
||||
|
||||
return countsMap;
|
||||
};
|
||||
|
||||
// Get song count using a pre-computed map
|
||||
export const getSongCountFromMap = (countsMap: Map<string, number>, artist: string, title: string): number => {
|
||||
const key = `${(artist || '').toLowerCase()}|${(title || '').toLowerCase()}`;
|
||||
return countsMap.get(key) || 0;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user