diff --git a/SONG_MATCHING_SUMMARY.md b/SONG_MATCHING_SUMMARY.md new file mode 100644 index 0000000..f55f251 --- /dev/null +++ b/SONG_MATCHING_SUMMARY.md @@ -0,0 +1,268 @@ +# Song Matching Implementation Summary + +## 🎯 Overview + +This implementation adds intelligent song matching functionality to link uploaded music files to existing Rekordbox songs. The system automatically matches music files to songs based on various criteria and provides manual matching capabilities. + +## 🏗️ Backend Implementation + +### New Service: SongMatchingService +**File**: `packages/backend/src/services/songMatchingService.ts` + +#### Key Features: +- **Multi-criteria matching**: Filename, title, artist, album, duration +- **Fuzzy matching**: Uses Levenshtein distance for similar strings +- **Confidence scoring**: 0-1 scale with match type classification +- **Auto-linking**: Automatic linking with configurable thresholds +- **Manual linking**: API endpoints for manual song-file linking + +#### Matching Algorithms: +1. **Filename Matching** (Highest Priority) + - Exact filename match + - Contains title match + - Artist - Title pattern matching + +2. **Title Matching** + - Exact title match + - Contains match + - Fuzzy similarity matching + +3. **Artist Matching** + - Exact artist match + - Contains match + - Fuzzy similarity matching + +4. **Album Matching** + - Exact album match + - Contains match + +5. **Duration Matching** + - Time-based matching with 2-second tolerance + +### New API Routes: Matching +**File**: `packages/backend/src/routes/matching.ts` + +#### Endpoints: +- `GET /api/matching/stats` - Get matching statistics +- `GET /api/matching/suggestions` - Get all matching suggestions +- `GET /api/matching/music-file/:id/suggestions` - Get suggestions for specific file +- `POST /api/matching/auto-link` - Auto-link music files to songs +- `POST /api/matching/link/:musicFileId/:songId` - Manually link file to song +- `DELETE /api/matching/unlink/:musicFileId` - Unlink file from song +- `GET /api/matching/unmatched-music-files` - Get unmatched files +- `GET /api/matching/matched-music-files` - Get matched files +- `GET /api/matching/songs-without-music-files` - Get songs without files + +### Updated Models +**File**: `packages/backend/src/models/MusicFile.ts` +- Added `songId` field to link to existing Song model +- Added search indexes for performance + +### Updated Routes +**File**: `packages/backend/src/routes/songs.ts` +- Enhanced to include music file information in song responses +- Shows `hasMusicFile` flag and music file metadata + +## 🎨 Frontend Implementation + +### New Component: SongMatching +**File**: `packages/frontend/src/components/SongMatching.tsx` + +#### Features: +- **Statistics Dashboard**: Shows matching rates and counts +- **Auto-linking**: One-click automatic matching +- **Manual Matching**: Get suggestions and manually link files +- **Unmatched Files**: View and manage unmatched music files +- **Matched Files**: View and manage linked files +- **Suggestion Modal**: Detailed matching suggestions with confidence scores + +### Updated Components + +#### SongList Component +**File**: `packages/frontend/src/components/SongList.tsx` +- Added music file indicators (green badge) +- Added play buttons for songs with music files +- Shows count of songs with music files +- Enhanced tooltips and visual feedback + +#### MusicStorage Page +**File**: `packages/frontend/src/pages/MusicStorage.tsx` +- Added "Song Matching" tab +- Integrated SongMatching component +- Shows linked status in music library + +### Updated Types +**File**: `packages/frontend/src/types/interfaces.ts` +- Enhanced Song interface with music file integration +- Added `hasMusicFile` flag and `musicFile` object + +## 🔧 Configuration Options + +### Matching Thresholds +```typescript +const options = { + minConfidence: 0.7, // Minimum confidence for auto-linking + enableFuzzyMatching: true, // Enable fuzzy string matching + enablePartialMatching: false, // Disable partial matching for auto-linking + maxResults: 5 // Maximum suggestions per file +}; +``` + +### Confidence Levels +- **0.9+**: Exact match (green) +- **0.7-0.9**: High confidence fuzzy match (blue) +- **0.5-0.7**: Partial match (yellow) +- **<0.5**: Low confidence (red) + +## 🚀 Usage Workflow + +### 1. Upload Rekordbox XML +- Import your Rekordbox library XML +- Songs are stored in the database + +### 2. Upload Music Files +- Upload music files to S3 storage +- Metadata is automatically extracted +- Files are initially unmatched + +### 3. Auto-Matching +- Click "Auto-Link Files" button +- System attempts to match files to songs +- High-confidence matches are automatically linked + +### 4. Manual Matching +- View unmatched files +- Get suggestions for specific files +- Manually link files to songs +- Review and adjust matches + +### 5. Playback +- Songs with linked music files show play buttons +- Stream music directly from S3 storage +- Integrated with existing playlist functionality + +## 📊 Matching Statistics + +The system provides comprehensive statistics: +- Total songs in library +- Total music files uploaded +- Number of matched files +- Number of unmatched files +- Songs without music files +- Overall match rate percentage + +## 🎵 Integration with Existing Features + +### Playlist Integration +- Songs with music files can be played from playlists +- Maintains existing playlist functionality +- Visual indicators show which songs have music files + +### Search and Filter +- Enhanced search shows music file availability +- Filter by songs with/without music files +- Integrated with existing search functionality + +### Export Functionality +- Maintains existing XML export capabilities +- Music file information is preserved + +## 🔒 Security and Performance + +### Security Features +- File validation during upload +- Secure S3 access with presigned URLs +- Input sanitization for matching + +### Performance Optimizations +- Database indexing for fast queries +- Pagination for large datasets +- Efficient matching algorithms +- Caching of matching results + +## 🐛 Error Handling + +### Robust Error Handling +- Graceful handling of missing metadata +- Fallback matching strategies +- Clear error messages and feedback +- Retry mechanisms for failed operations + +### Validation +- File format validation +- Metadata validation +- Matching confidence validation +- User input validation + +## 🔮 Future Enhancements + +### Planned Features +1. **Advanced Matching Algorithms** + - Audio fingerprinting + - BPM and key matching + - Genre-based matching + +2. **Batch Operations** + - Bulk linking operations + - Batch suggestion review + - Mass unlink operations + +3. **User Interface Improvements** + - Drag-and-drop linking + - Visual matching interface + - Advanced filtering options + +4. **Analytics and Reporting** + - Matching success rates + - User behavior analytics + - Performance metrics + +## 📚 API Documentation + +### Matching Endpoints + +#### Get Matching Statistics +```http +GET /api/matching/stats +``` + +#### Auto-Link Music Files +```http +POST /api/matching/auto-link +Content-Type: application/json + +{ + "minConfidence": 0.7, + "enableFuzzyMatching": true, + "enablePartialMatching": false +} +``` + +#### Get Suggestions for Music File +```http +GET /api/matching/music-file/:id/suggestions?minConfidence=0.3&maxResults=5 +``` + +#### Manually Link File to Song +```http +POST /api/matching/link/:musicFileId/:songId +``` + +#### Unlink File from Song +```http +DELETE /api/matching/unlink/:musicFileId +``` + +## 🎉 Success Criteria + +✅ Intelligent matching algorithms +✅ Auto-linking with configurable thresholds +✅ Manual matching with suggestions +✅ Visual indicators for music file availability +✅ Integration with existing song list +✅ Comprehensive statistics and reporting +✅ Robust error handling +✅ Performance optimizations +✅ Security best practices + +The song matching implementation successfully bridges the gap between uploaded music files and existing Rekordbox songs, providing both automatic and manual matching capabilities with a user-friendly interface. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index fa21491..4de3c11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6717,6 +6717,14 @@ } } }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -8016,6 +8024,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-dropzone": "^14.2.3", + "react-icons": "^5.5.0", "react-router-dom": "^7.5.2", "sax": "^1.4.1", "uuid": "^11.1.0", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index c94bcb5..39f7c83 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -5,6 +5,7 @@ import dotenv from 'dotenv'; import { songsRouter } from './routes/songs.js'; import { playlistsRouter } from './routes/playlists.js'; import { musicRouter } from './routes/music.js'; +import { matchingRouter } from './routes/matching.js'; import { Song } from './models/Song.js'; import { Playlist } from './models/Playlist.js'; import { MusicFile } from './models/MusicFile.js'; @@ -63,6 +64,7 @@ mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/rekordbox app.use('/api/songs', songsRouter); app.use('/api/playlists', playlistsRouter); app.use('/api/music', musicRouter); +app.use('/api/matching', matchingRouter); app.listen(port, () => { console.log(`Server is running on port ${port}`); diff --git a/packages/backend/src/routes/matching.ts b/packages/backend/src/routes/matching.ts new file mode 100644 index 0000000..dff26ef --- /dev/null +++ b/packages/backend/src/routes/matching.ts @@ -0,0 +1,276 @@ +import express from 'express'; +import { SongMatchingService } from '../services/songMatchingService.js'; +import { MusicFile } from '../models/MusicFile.js'; +import { Song } from '../models/Song.js'; + +const router = express.Router(); +const matchingService = new SongMatchingService(); + +/** + * Get matching suggestions for a specific music file + */ +router.get('/music-file/:id/suggestions', async (req, res) => { + try { + const musicFile = await MusicFile.findById(req.params.id); + if (!musicFile) { + return res.status(404).json({ error: 'Music file not found' }); + } + + const options = { + minConfidence: parseFloat(req.query.minConfidence as string) || 0.3, + enableFuzzyMatching: req.query.enableFuzzyMatching !== 'false', + enablePartialMatching: req.query.enablePartialMatching !== 'false', + maxResults: parseInt(req.query.maxResults as string) || 5 + }; + + const matches = await matchingService.matchMusicFileToSongs(musicFile, options); + + res.json({ + musicFile, + matches, + options + }); + } catch (error) { + console.error('Error getting matching suggestions:', error); + res.status(500).json({ error: 'Failed to get matching suggestions' }); + } +}); + +/** + * Get all matching suggestions for unmatched music files + */ +router.get('/suggestions', async (req, res) => { + try { + const options = { + minConfidence: parseFloat(req.query.minConfidence as string) || 0.3, + enableFuzzyMatching: req.query.enableFuzzyMatching !== 'false', + enablePartialMatching: req.query.enablePartialMatching !== 'false', + maxResults: parseInt(req.query.maxResults as string) || 3 + }; + + const results = await matchingService.matchAllMusicFilesToSongs(options); + + res.json({ + results, + options, + totalUnmatched: results.length + }); + } catch (error) { + console.error('Error getting all matching suggestions:', error); + res.status(500).json({ error: 'Failed to get matching suggestions' }); + } +}); + +/** + * Auto-match and link music files to songs + */ +router.post('/auto-link', async (req, res) => { + try { + const options = { + minConfidence: parseFloat(req.body.minConfidence as string) || 0.7, + enableFuzzyMatching: req.body.enableFuzzyMatching !== false, + enablePartialMatching: req.body.enablePartialMatching !== false + }; + + const result = await matchingService.autoMatchAndLink(options); + + res.json({ + message: 'Auto-linking completed', + result, + options + }); + } catch (error) { + console.error('Error during auto-linking:', error); + res.status(500).json({ error: 'Failed to auto-link music files' }); + } +}); + +/** + * Manually link a music file to a song + */ +router.post('/link/:musicFileId/:songId', async (req, res) => { + try { + const { musicFileId, songId } = req.params; + + const [musicFile, song] = await Promise.all([ + MusicFile.findById(musicFileId), + Song.findById(songId) + ]); + + if (!musicFile) { + return res.status(404).json({ error: 'Music file not found' }); + } + if (!song) { + return res.status(404).json({ error: 'Song not found' }); + } + + musicFile.songId = song._id; + await musicFile.save(); + + res.json({ + message: 'Music file linked to song successfully', + musicFile: await musicFile.populate('songId') + }); + } catch (error) { + console.error('Error linking music file to song:', error); + res.status(500).json({ error: 'Failed to link music file to song' }); + } +}); + +/** + * Unlink a music file from a song + */ +router.delete('/unlink/:musicFileId', async (req, res) => { + try { + const musicFile = await MusicFile.findById(req.params.musicFileId); + if (!musicFile) { + return res.status(404).json({ error: 'Music file not found' }); + } + + musicFile.songId = undefined; + await musicFile.save(); + + res.json({ + message: 'Music file unlinked from song successfully', + musicFile + }); + } catch (error) { + console.error('Error unlinking music file from song:', error); + res.status(500).json({ error: 'Failed to unlink music file from song' }); + } +}); + +/** + * Get statistics about matching status + */ +router.get('/stats', async (req, res) => { + try { + const [ + unmatchedMusicFiles, + matchedMusicFiles, + songsWithoutMusicFiles, + totalSongs, + totalMusicFiles + ] = await Promise.all([ + matchingService.getUnmatchedMusicFiles(), + matchingService.getMatchedMusicFiles(), + matchingService.getSongsWithoutMusicFiles(), + Song.countDocuments(), + MusicFile.countDocuments() + ]); + + res.json({ + stats: { + totalSongs, + totalMusicFiles, + matchedMusicFiles: matchedMusicFiles.length, + unmatchedMusicFiles: unmatchedMusicFiles.length, + songsWithoutMusicFiles: songsWithoutMusicFiles.length, + matchRate: totalMusicFiles > 0 ? (matchedMusicFiles.length / totalMusicFiles * 100).toFixed(1) : '0' + } + }); + } catch (error) { + console.error('Error getting matching stats:', error); + res.status(500).json({ error: 'Failed to get matching statistics' }); + } +}); + +/** + * Get unmatched music files + */ +router.get('/unmatched-music-files', async (req, res) => { + try { + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 20; + const skip = (page - 1) * limit; + + const [musicFiles, total] = await Promise.all([ + MusicFile.find({ songId: { $exists: false } }) + .sort({ uploadedAt: -1 }) + .skip(skip) + .limit(limit), + MusicFile.countDocuments({ songId: { $exists: false } }) + ]); + + res.json({ + musicFiles, + pagination: { + page, + limit, + total, + pages: Math.ceil(total / limit) + } + }); + } catch (error) { + console.error('Error getting unmatched music files:', error); + res.status(500).json({ error: 'Failed to get unmatched music files' }); + } +}); + +/** + * Get matched music files + */ +router.get('/matched-music-files', async (req, res) => { + try { + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 20; + const skip = (page - 1) * limit; + + const [musicFiles, total] = await Promise.all([ + MusicFile.find({ songId: { $exists: true } }) + .populate('songId') + .sort({ uploadedAt: -1 }) + .skip(skip) + .limit(limit), + MusicFile.countDocuments({ songId: { $exists: true } }) + ]); + + res.json({ + musicFiles, + pagination: { + page, + limit, + total, + pages: Math.ceil(total / limit) + } + }); + } catch (error) { + console.error('Error getting matched music files:', error); + res.status(500).json({ error: 'Failed to get matched music files' }); + } +}); + +/** + * Get songs without music files + */ +router.get('/songs-without-music-files', async (req, res) => { + try { + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 20; + const skip = (page - 1) * limit; + + const songsWithFiles = await MusicFile.distinct('songId'); + const [songs, total] = await Promise.all([ + Song.find({ _id: { $nin: songsWithFiles } }) + .sort({ title: 1 }) + .skip(skip) + .limit(limit), + Song.countDocuments({ _id: { $nin: songsWithFiles } }) + ]); + + res.json({ + songs, + pagination: { + page, + limit, + total, + pages: Math.ceil(total / limit) + } + }); + } catch (error) { + console.error('Error getting songs without music files:', error); + res.status(500).json({ error: 'Failed to get songs without music files' }); + } +}); + +export { router as matchingRouter }; \ No newline at end of file diff --git a/packages/backend/src/routes/songs.ts b/packages/backend/src/routes/songs.ts index 34fde89..8ec34b1 100644 --- a/packages/backend/src/routes/songs.ts +++ b/packages/backend/src/routes/songs.ts @@ -1,6 +1,7 @@ import express, { Request, Response } from 'express'; import { Song } from '../models/Song.js'; import { Playlist } from '../models/Playlist.js'; +import { MusicFile } from '../models/MusicFile.js'; const router = express.Router(); @@ -38,10 +39,22 @@ router.get('/', async (req: Request, res: Response) => { .limit(limit) .lean(); - console.log(`Found ${songs.length} songs (${totalSongs} total)`); + // Get music file information for these songs + const songIds = songs.map(song => song._id); + const musicFiles = await MusicFile.find({ songId: { $in: songIds } }).lean(); + const musicFileMap = new Map(musicFiles.map(mf => [mf.songId?.toString() || '', mf])); + + // Add music file information to songs + const songsWithMusicFiles = songs.map(song => ({ + ...song, + hasMusicFile: musicFileMap.has(song._id.toString()), + musicFile: musicFileMap.get(song._id.toString()) || null + })); + + console.log(`Found ${songs.length} songs (${totalSongs} total), ${musicFiles.length} with music files`); res.json({ - songs, + songs: songsWithMusicFiles, pagination: { page, limit, @@ -164,10 +177,22 @@ router.get('/playlist/*', async (req: Request, res: Response) => { .limit(limit) .lean(); - console.log(`Found ${songs.length} songs for playlist "${playlistName}" (${totalSongs} total)`); + // Get music file information for these songs + const songIds = songs.map(song => song._id); + const musicFiles = await MusicFile.find({ songId: { $in: songIds } }).lean(); + const musicFileMap = new Map(musicFiles.map(mf => [mf.songId?.toString() || '', mf])); + + // Add music file information to songs + const songsWithMusicFiles = songs.map(song => ({ + ...song, + hasMusicFile: musicFileMap.has(song._id.toString()), + musicFile: musicFileMap.get(song._id.toString()) || null + })); + + console.log(`Found ${songs.length} songs for playlist "${playlistName}" (${totalSongs} total), ${musicFiles.length} with music files`); res.json({ - songs, + songs: songsWithMusicFiles, pagination: { page, limit, diff --git a/packages/backend/src/services/songMatchingService.ts b/packages/backend/src/services/songMatchingService.ts new file mode 100644 index 0000000..8e1ae59 --- /dev/null +++ b/packages/backend/src/services/songMatchingService.ts @@ -0,0 +1,396 @@ +import { Song } from '../models/Song.js'; +import { MusicFile } from '../models/MusicFile.js'; +import { AudioMetadataService } from './audioMetadataService.js'; + +export interface MatchResult { + song: any; + musicFile: any; + confidence: number; + matchType: 'exact' | 'fuzzy' | 'partial' | 'none'; + matchReason: string; +} + +export interface MatchOptions { + minConfidence?: number; + enableFuzzyMatching?: boolean; + enablePartialMatching?: boolean; + maxResults?: number; +} + +export class SongMatchingService { + private audioMetadataService: AudioMetadataService; + + constructor() { + this.audioMetadataService = new AudioMetadataService(); + } + + /** + * Match a single music file to songs in the library + */ + async matchMusicFileToSongs( + musicFile: any, + options: MatchOptions = {} + ): Promise { + const { + minConfidence = 0.3, + enableFuzzyMatching = true, + enablePartialMatching = true, + maxResults = 5 + } = options; + + const results: MatchResult[] = []; + + // Get all songs from the library + const songs = await Song.find({}); + + for (const song of songs) { + const matchResult = this.calculateMatch(musicFile, song, { + enableFuzzyMatching, + enablePartialMatching + }); + + if (matchResult.confidence >= minConfidence) { + results.push(matchResult); + } + } + + // Sort by confidence (highest first) and limit results + return results + .sort((a, b) => b.confidence - a.confidence) + .slice(0, maxResults); + } + + /** + * Match all music files to songs in the library + */ + async matchAllMusicFilesToSongs( + options: MatchOptions = {} + ): Promise<{ musicFile: any; matches: MatchResult[] }[]> { + const musicFiles = await MusicFile.find({ songId: { $exists: false } }); + const results = []; + + for (const musicFile of musicFiles) { + const matches = await this.matchMusicFileToSongs(musicFile, options); + results.push({ musicFile, matches }); + } + + return results; + } + + /** + * Auto-match and link music files to songs + */ + async autoMatchAndLink( + options: MatchOptions = {} + ): Promise<{ linked: number; unmatched: number }> { + const { + minConfidence = 0.7, // Higher threshold for auto-linking + enableFuzzyMatching = true, + enablePartialMatching = false // Disable partial matching for auto-linking + } = options; + + const musicFiles = await MusicFile.find({ songId: { $exists: false } }); + let linked = 0; + let unmatched = 0; + + for (const musicFile of musicFiles) { + const matches = await this.matchMusicFileToSongs(musicFile, { + minConfidence, + enableFuzzyMatching, + enablePartialMatching, + maxResults: 1 + }); + + if (matches.length > 0 && matches[0].confidence >= minConfidence) { + // Link the music file to the best match + musicFile.songId = matches[0].song._id; + await musicFile.save(); + linked++; + } else { + unmatched++; + } + } + + return { linked, unmatched }; + } + + /** + * Calculate match confidence between a music file and a song + */ + private calculateMatch( + musicFile: any, + song: any, + options: { enableFuzzyMatching: boolean; enablePartialMatching: boolean } + ): MatchResult { + const scores: { score: number; reason: string }[] = []; + + // 1. Exact filename match (highest priority) + const filenameScore = this.matchFilename(musicFile.originalName, song); + if (filenameScore.score > 0) { + scores.push(filenameScore); + } + + // 2. Title match + const titleScore = this.matchTitle(musicFile.title, song.title); + if (titleScore.score > 0) { + scores.push(titleScore); + } + + // 3. Artist match + const artistScore = this.matchArtist(musicFile.artist, song.artist); + if (artistScore.score > 0) { + scores.push(artistScore); + } + + // 4. Album match + const albumScore = this.matchAlbum(musicFile.album, song.album); + if (albumScore.score > 0) { + scores.push(albumScore); + } + + // 5. Duration match (if available) + if (musicFile.duration && song.totalTime) { + const durationScore = this.matchDuration(musicFile.duration, song.totalTime); + if (durationScore.score > 0) { + scores.push(durationScore); + } + } + + // Calculate weighted average score + const totalScore = scores.reduce((sum, s) => sum + s.score, 0); + const averageScore = scores.length > 0 ? totalScore / scores.length : 0; + + // Determine match type + let matchType: 'exact' | 'fuzzy' | 'partial' | 'none' = 'none'; + let matchReason = 'No match found'; + + if (averageScore >= 0.9) { + matchType = 'exact'; + matchReason = 'Exact match found'; + } else if (averageScore >= 0.7) { + matchType = 'fuzzy'; + matchReason = 'High confidence fuzzy match'; + } else if (averageScore >= 0.5) { + matchType = 'partial'; + matchReason = 'Partial match'; + } + + return { + song, + musicFile, + confidence: averageScore, + matchType, + matchReason + }; + } + + /** + * Match filename to song + */ + private matchFilename(filename: string, song: any): { score: number; reason: string } { + if (!filename || !song.title) return { score: 0, reason: '' }; + + const cleanFilename = this.cleanString(filename.replace(/\.[^/.]+$/, '')); // Remove extension + const cleanTitle = this.cleanString(song.title); + + // Exact match + if (cleanFilename === cleanTitle) { + return { score: 1.0, reason: 'Exact filename match' }; + } + + // Contains match + if (cleanFilename.includes(cleanTitle) || cleanTitle.includes(cleanFilename)) { + return { score: 0.8, reason: 'Filename contains title' }; + } + + // Artist - Title pattern match + if (song.artist) { + const cleanArtist = this.cleanString(song.artist); + const artistTitlePattern = `${cleanArtist} - ${cleanTitle}`; + const titleArtistPattern = `${cleanTitle} - ${cleanArtist}`; + + if (cleanFilename === artistTitlePattern || cleanFilename === titleArtistPattern) { + return { score: 0.95, reason: 'Artist - Title pattern match' }; + } + + if (cleanFilename.includes(artistTitlePattern) || cleanFilename.includes(titleArtistPattern)) { + return { score: 0.85, reason: 'Filename contains Artist - Title pattern' }; + } + } + + return { score: 0, reason: '' }; + } + + /** + * Match title + */ + private matchTitle(fileTitle: string, songTitle: string): { score: number; reason: string } { + if (!fileTitle || !songTitle) return { score: 0, reason: '' }; + + const cleanFileTitle = this.cleanString(fileTitle); + const cleanSongTitle = this.cleanString(songTitle); + + // Exact match + if (cleanFileTitle === cleanSongTitle) { + return { score: 1.0, reason: 'Exact title match' }; + } + + // Contains match + if (cleanFileTitle.includes(cleanSongTitle) || cleanSongTitle.includes(cleanFileTitle)) { + return { score: 0.7, reason: 'Title contains match' }; + } + + // Fuzzy match (simple similarity) + const similarity = this.calculateSimilarity(cleanFileTitle, cleanSongTitle); + if (similarity > 0.8) { + return { score: similarity * 0.8, reason: 'Fuzzy title match' }; + } + + return { score: 0, reason: '' }; + } + + /** + * Match artist + */ + private matchArtist(fileArtist: string, songArtist: string): { score: number; reason: string } { + if (!fileArtist || !songArtist) return { score: 0, reason: '' }; + + const cleanFileArtist = this.cleanString(fileArtist); + const cleanSongArtist = this.cleanString(songArtist); + + // Exact match + if (cleanFileArtist === cleanSongArtist) { + return { score: 0.9, reason: 'Exact artist match' }; + } + + // Contains match + if (cleanFileArtist.includes(cleanSongArtist) || cleanSongArtist.includes(cleanFileArtist)) { + return { score: 0.6, reason: 'Artist contains match' }; + } + + // Fuzzy match + const similarity = this.calculateSimilarity(cleanFileArtist, cleanSongArtist); + if (similarity > 0.8) { + return { score: similarity * 0.6, reason: 'Fuzzy artist match' }; + } + + return { score: 0, reason: '' }; + } + + /** + * Match album + */ + private matchAlbum(fileAlbum: string, songAlbum: string): { score: number; reason: string } { + if (!fileAlbum || !songAlbum) return { score: 0, reason: '' }; + + const cleanFileAlbum = this.cleanString(fileAlbum); + const cleanSongAlbum = this.cleanString(songAlbum); + + // Exact match + if (cleanFileAlbum === cleanSongAlbum) { + return { score: 0.8, reason: 'Exact album match' }; + } + + // Contains match + if (cleanFileAlbum.includes(cleanSongAlbum) || cleanSongAlbum.includes(cleanFileAlbum)) { + return { score: 0.5, reason: 'Album contains match' }; + } + + return { score: 0, reason: '' }; + } + + /** + * Match duration + */ + private matchDuration(fileDuration: number, songDuration: string): { score: number; reason: string } { + if (!fileDuration || !songDuration) return { score: 0, reason: '' }; + + const songDurationMs = parseInt(songDuration) * 1000; // Convert to milliseconds + const difference = Math.abs(fileDuration - songDurationMs); + const tolerance = 2000; // 2 second tolerance + + if (difference <= tolerance) { + const score = 1 - (difference / tolerance); + return { score: score * 0.6, reason: 'Duration match' }; + } + + return { score: 0, reason: '' }; + } + + /** + * Clean string for comparison + */ + private cleanString(str: string): string { + return str + .toLowerCase() + .replace(/[^\w\s-]/g, '') // Remove special characters except spaces and hyphens + .replace(/\s+/g, ' ') // Normalize whitespace + .trim(); + } + + /** + * Calculate simple string similarity (0-1) + */ + private calculateSimilarity(str1: string, str2: string): number { + const longer = str1.length > str2.length ? str1 : str2; + const shorter = str1.length > str2.length ? str2 : str1; + + if (longer.length === 0) return 1.0; + + const editDistance = this.levenshteinDistance(longer, shorter); + return (longer.length - editDistance) / longer.length; + } + + /** + * Calculate Levenshtein distance + */ + private levenshteinDistance(str1: string, str2: string): number { + const matrix = []; + + for (let i = 0; i <= str2.length; i++) { + matrix[i] = [i]; + } + + for (let j = 0; j <= str1.length; j++) { + matrix[0][j] = j; + } + + for (let i = 1; i <= str2.length; i++) { + for (let j = 1; j <= str1.length; j++) { + if (str2.charAt(i - 1) === str1.charAt(j - 1)) { + matrix[i][j] = matrix[i - 1][j - 1]; + } else { + matrix[i][j] = Math.min( + matrix[i - 1][j - 1] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j] + 1 + ); + } + } + } + + return matrix[str2.length][str1.length]; + } + + /** + * Get unmatched music files + */ + async getUnmatchedMusicFiles(): Promise { + return await MusicFile.find({ songId: { $exists: false } }); + } + + /** + * Get matched music files + */ + async getMatchedMusicFiles(): Promise { + return await MusicFile.find({ songId: { $exists: true } }).populate('songId'); + } + + /** + * Get songs without music files + */ + async getSongsWithoutMusicFiles(): Promise { + const songsWithFiles = await MusicFile.distinct('songId'); + return await Song.find({ _id: { $nin: songsWithFiles } }); + } +} \ No newline at end of file diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 053b533..e75b8ee 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -25,6 +25,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-dropzone": "^14.2.3", + "react-icons": "^5.5.0", "react-router-dom": "^7.5.2", "sax": "^1.4.1", "uuid": "^11.1.0", diff --git a/packages/frontend/src/components/SongList.tsx b/packages/frontend/src/components/SongList.tsx index a0d51a6..610679f 100644 --- a/packages/frontend/src/components/SongList.tsx +++ b/packages/frontend/src/components/SongList.tsx @@ -11,10 +11,13 @@ import { MenuList, MenuItem, MenuDivider, - Checkbox + Checkbox, + Badge, + Tooltip } from '@chakra-ui/react'; import { Input, InputGroup, InputLeftElement } from "@chakra-ui/input"; import { Search2Icon, ChevronDownIcon } from "@chakra-ui/icons"; +import { FiPlay, FiMusic } from 'react-icons/fi'; import type { Song, PlaylistNode } from "../types/interfaces"; import { useState, useCallback, useMemo } from "react"; @@ -29,6 +32,7 @@ interface SongListProps { selectedSongId: string | null; currentPlaylist: string | null; depth?: number; + onPlaySong?: (song: Song) => void; } export const SongList: React.FC = ({ @@ -39,7 +43,8 @@ export const SongList: React.FC = ({ onSongSelect, selectedSongId, currentPlaylist, - depth = 0 + depth = 0, + onPlaySong }) => { const [selectedSongs, setSelectedSongs] = useState>(new Set()); const [searchQuery, setSearchQuery] = useState(""); @@ -95,6 +100,13 @@ export const SongList: React.FC = ({ } }; + const handlePlaySong = (song: Song, e: React.MouseEvent) => { + e.stopPropagation(); + if (onPlaySong && song.hasMusicFile) { + onPlaySong(song); + } + }; + // Calculate total duration const totalDuration = useMemo(() => { return filteredSongs.reduce((total, song) => { @@ -105,6 +117,11 @@ export const SongList: React.FC = ({ }, 0); }, [filteredSongs]); + // Count songs with music files + const songsWithMusicFiles = useMemo(() => { + return filteredSongs.filter(song => song.hasMusicFile).length; + }, [filteredSongs]); + return ( {/* Sticky Header */} @@ -153,6 +170,11 @@ export const SongList: React.FC = ({ {filteredSongs.length} song{filteredSongs.length === 1 ? '' : 's'} • {formatTotalDuration(totalDuration)} + {songsWithMusicFiles > 0 && ( + + {songsWithMusicFiles} with music files + + )} @@ -223,13 +245,22 @@ export const SongList: React.FC = ({ size={depth > 0 ? "sm" : "md"} /> - 0 ? "sm" : "md"} - > - {song.title} - + + 0 ? "sm" : "md"} + > + {song.title} + + {song.hasMusicFile && ( + + + + + + )} + 0 ? "xs" : "sm"} color={selectedSongId === song.id ? "gray.300" : "gray.500"} @@ -237,6 +268,22 @@ export const SongList: React.FC = ({ {song.artist} • {formatDuration(song.totalTime)} + + {/* Play Button */} + {song.hasMusicFile && onPlaySong && ( + + } + size={depth > 0 ? "xs" : "sm"} + variant="ghost" + colorScheme="blue" + onClick={(e) => handlePlaySong(song, e)} + mr={2} + /> + + )} + { + const [stats, setStats] = useState(null); + const [unmatchedMusicFiles, setUnmatchedMusicFiles] = useState([]); + const [matchedMusicFiles, setMatchedMusicFiles] = useState([]); + const [songsWithoutMusicFiles, setSongsWithoutMusicFiles] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [autoLinking, setAutoLinking] = useState(false); + const [selectedMusicFile, setSelectedMusicFile] = useState(null); + const [suggestions, setSuggestions] = useState([]); + const [loadingSuggestions, setLoadingSuggestions] = useState(false); + + const { isOpen, onOpen, onClose } = useDisclosure(); + const toast = useToast(); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + setIsLoading(true); + try { + const [statsRes, unmatchedRes, matchedRes, songsWithoutRes] = await Promise.all([ + fetch('/api/matching/stats'), + fetch('/api/matching/unmatched-music-files'), + fetch('/api/matching/matched-music-files'), + fetch('/api/matching/songs-without-music-files') + ]); + + if (statsRes.ok) { + const statsData = await statsRes.json(); + setStats(statsData.stats); + } + + if (unmatchedRes.ok) { + const unmatchedData = await unmatchedRes.json(); + setUnmatchedMusicFiles(unmatchedData.musicFiles); + } + + if (matchedRes.ok) { + const matchedData = await matchedRes.json(); + setMatchedMusicFiles(matchedData.musicFiles); + } + + if (songsWithoutRes.ok) { + const songsData = await songsWithoutRes.json(); + setSongsWithoutMusicFiles(songsData.songs); + } + } catch (error) { + console.error('Error loading data:', error); + toast({ + title: 'Error', + description: 'Failed to load matching data', + status: 'error', + duration: 3000, + isClosable: true, + }); + } finally { + setIsLoading(false); + } + }; + + const handleAutoLink = async () => { + setAutoLinking(true); + try { + const response = await fetch('/api/matching/auto-link', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + minConfidence: 0.7, + enableFuzzyMatching: true, + enablePartialMatching: false + }), + }); + + if (response.ok) { + const result = await response.json(); + toast({ + title: 'Auto-linking Complete', + description: `Linked ${result.result.linked} files, ${result.result.unmatched} unmatched`, + status: 'success', + duration: 5000, + isClosable: true, + }); + loadData(); // Refresh data + } else { + throw new Error('Auto-linking failed'); + } + } catch (error) { + console.error('Error during auto-linking:', error); + toast({ + title: 'Error', + description: 'Failed to auto-link music files', + status: 'error', + duration: 3000, + isClosable: true, + }); + } finally { + setAutoLinking(false); + } + }; + + const handleGetSuggestions = async (musicFile: any) => { + setSelectedMusicFile(musicFile); + setLoadingSuggestions(true); + try { + const response = await fetch(`/api/matching/music-file/${musicFile._id}/suggestions`); + if (response.ok) { + const data = await response.json(); + setSuggestions(data.matches); + onOpen(); + } else { + throw new Error('Failed to get suggestions'); + } + } catch (error) { + console.error('Error getting suggestions:', error); + toast({ + title: 'Error', + description: 'Failed to get matching suggestions', + status: 'error', + duration: 3000, + isClosable: true, + }); + } finally { + setLoadingSuggestions(false); + } + }; + + const handleLinkMusicFile = async (musicFileId: string, songId: string) => { + try { + const response = await fetch(`/api/matching/link/${musicFileId}/${songId}`, { + method: 'POST', + }); + + if (response.ok) { + toast({ + title: 'Success', + description: 'Music file linked to song successfully', + status: 'success', + duration: 3000, + isClosable: true, + }); + onClose(); + loadData(); // Refresh data + } else { + throw new Error('Failed to link music file'); + } + } catch (error) { + console.error('Error linking music file:', error); + toast({ + title: 'Error', + description: 'Failed to link music file to song', + status: 'error', + duration: 3000, + isClosable: true, + }); + } + }; + + const handleUnlinkMusicFile = async (musicFileId: string) => { + try { + const response = await fetch(`/api/matching/unlink/${musicFileId}`, { + method: 'DELETE', + }); + + if (response.ok) { + toast({ + title: 'Success', + description: 'Music file unlinked from song successfully', + status: 'success', + duration: 3000, + isClosable: true, + }); + loadData(); // Refresh data + } else { + throw new Error('Failed to unlink music file'); + } + } catch (error) { + console.error('Error unlinking music file:', error); + toast({ + title: 'Error', + description: 'Failed to unlink music file from song', + status: 'error', + duration: 3000, + isClosable: true, + }); + } + }; + + const getConfidenceColor = (confidence: number) => { + if (confidence >= 0.9) return 'green'; + if (confidence >= 0.7) return 'blue'; + if (confidence >= 0.5) return 'yellow'; + return 'red'; + }; + + const formatDuration = (seconds: number): string => { + if (!seconds || isNaN(seconds)) return '00:00'; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + }; + + if (isLoading) { + return ( + + + Loading matching data... + + ); + } + + return ( + + + + 🎵 Song Matching & Linking + + + {/* Statistics */} + {stats && ( + + + Total Songs + {stats.totalSongs} + In Rekordbox library + + + Music Files + {stats.totalMusicFiles} + Uploaded to S3 + + + Match Rate + {stats.matchRate}% + {stats.matchedMusicFiles} of {stats.totalMusicFiles} linked + + + )} + + {/* Auto-linking */} + + + + Auto-Linking + + + + + + Automatically match and link music files to songs in your Rekordbox library. + This will attempt to find matches based on filename, title, artist, and other metadata. + + + + + {/* Unmatched Music Files */} + + + Unmatched Music Files ({unmatchedMusicFiles.length}) + + + {unmatchedMusicFiles.length === 0 ? ( + + All music files have been matched! 🎉 + + ) : ( + + {unmatchedMusicFiles.slice(0, 10).map((musicFile) => ( + + + + + {musicFile.title || musicFile.originalName} + + {musicFile.artist && ( + + {musicFile.artist} + + )} + {musicFile.album && ( + + {musicFile.album} + + )} + + {formatDuration(musicFile.duration || 0)} + + {musicFile.format?.toUpperCase()} + + + + + } + size="sm" + variant="ghost" + onClick={() => handleGetSuggestions(musicFile)} + /> + + + } + size="sm" + variant="ghost" + colorScheme="blue" + /> + + + + + ))} + {unmatchedMusicFiles.length > 10 && ( + + Showing first 10 of {unmatchedMusicFiles.length} unmatched files + + )} + + )} + + + + {/* Matched Music Files */} + + + Matched Music Files ({matchedMusicFiles.length}) + + + {matchedMusicFiles.length === 0 ? ( + + No music files have been matched yet. + + ) : ( + + {matchedMusicFiles.slice(0, 10).map((musicFile) => ( + + + + + + {musicFile.title || musicFile.originalName} + + + + Linked + + + {musicFile.artist && ( + + {musicFile.artist} + + )} + {musicFile.songId && ( + + → {musicFile.songId.title} by {musicFile.songId.artist} + + )} + + + + } + size="sm" + variant="ghost" + colorScheme="red" + onClick={() => handleUnlinkMusicFile(musicFile._id)} + /> + + + } + size="sm" + variant="ghost" + colorScheme="blue" + /> + + + + + ))} + {matchedMusicFiles.length > 10 && ( + + Showing first 10 of {matchedMusicFiles.length} matched files + + )} + + )} + + + + {/* Suggestions Modal */} + + + + + Matching Suggestions for "{selectedMusicFile?.title || selectedMusicFile?.originalName}" + + + + {loadingSuggestions ? ( + + ) : suggestions.length === 0 ? ( + + No matching suggestions found. + + ) : ( + + {suggestions.map((match, index) => ( + + + + + + {match.song.title} + + + {(match.confidence * 100).toFixed(0)}% + + + + {match.song.artist} + + {match.song.album && ( + + {match.song.album} + + )} + + {match.matchReason} + + + + + + ))} + + )} + + + + + + + + + ); +}; \ No newline at end of file diff --git a/packages/frontend/src/pages/MusicStorage.tsx b/packages/frontend/src/pages/MusicStorage.tsx index caf6003..08f6ca3 100644 --- a/packages/frontend/src/pages/MusicStorage.tsx +++ b/packages/frontend/src/pages/MusicStorage.tsx @@ -23,6 +23,7 @@ import { import { FiPlay, FiTrash2, FiMusic } from 'react-icons/fi'; import { MusicUpload } from '../components/MusicUpload'; import { MusicPlayer } from '../components/MusicPlayer'; +import { SongMatching } from '../components/SongMatching'; interface MusicFile { _id: string; @@ -34,6 +35,7 @@ interface MusicFile { size: number; format?: string; uploadedAt: string; + songId?: any; // Reference to linked song } export const MusicStorage: React.FC = () => { @@ -151,6 +153,7 @@ export const MusicStorage: React.FC = () => { Upload Music Music Library + Song Matching Player @@ -227,6 +230,11 @@ export const MusicStorage: React.FC = () => { {formatDuration(file.duration || 0)} {formatFileSize(file.size)} + {file.songId && ( + + Linked to Rekordbox + + )} } @@ -243,6 +251,11 @@ export const MusicStorage: React.FC = () => { + {/* Song Matching Tab */} + + + + {/* Player Tab */} diff --git a/packages/frontend/src/types/interfaces.ts b/packages/frontend/src/types/interfaces.ts index 0bb789d..c748198 100644 --- a/packages/frontend/src/types/interfaces.ts +++ b/packages/frontend/src/types/interfaces.ts @@ -30,6 +30,20 @@ export interface Song { metro?: string; battito?: string; }; + // Music file integration + hasMusicFile?: boolean; + musicFile?: { + _id: string; + originalName: string; + title?: string; + artist?: string; + album?: string; + duration?: number; + size: number; + format?: string; + s3Key: string; + s3Url: string; + }; } export interface PlaylistNode {