feat: Preserve original file paths and add S3 information alongside
- Update Song model to preserve original location field from Rekordbox XML - Add s3File object to Song model with S3 information alongside original location - Update SongMatchingService to link S3 files while preserving original paths - Add location-based matching for better accuracy - Update XML export to include S3 information while preserving original location - Update frontend components to show both original paths and S3 information - Add new section in SongMatching to show songs with music files - Enhance SongList to display original file paths with folder icon This ensures that: - Original file paths from Rekordbox XML are preserved - S3 information is added alongside, not replacing original data - XML export maintains compatibility while adding S3 data - Users can see both original paths and S3 streaming URLs - Matching algorithms consider original file paths for better accuracy
This commit is contained in:
parent
8684f2e59d
commit
b120a7cf6d
@ -28,12 +28,20 @@ const songSchema = new mongoose.Schema({
|
|||||||
comments: String,
|
comments: String,
|
||||||
playCount: String,
|
playCount: String,
|
||||||
rating: String,
|
rating: String,
|
||||||
location: String,
|
location: String, // Original file path from Rekordbox XML
|
||||||
remixer: String,
|
remixer: String,
|
||||||
tonality: String,
|
tonality: String,
|
||||||
label: String,
|
label: String,
|
||||||
mix: String,
|
mix: String,
|
||||||
tempo: tempoSchema,
|
tempo: tempoSchema,
|
||||||
|
// S3 file integration (preserves original location)
|
||||||
|
s3File: {
|
||||||
|
musicFileId: { type: mongoose.Schema.Types.ObjectId, ref: 'MusicFile' },
|
||||||
|
s3Key: String,
|
||||||
|
s3Url: String,
|
||||||
|
streamingUrl: String,
|
||||||
|
hasS3File: { type: Boolean, default: false }
|
||||||
|
}
|
||||||
}, {
|
}, {
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
versionKey: false,
|
versionKey: false,
|
||||||
@ -47,4 +55,8 @@ const songSchema = new mongoose.Schema({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create indexes for performance
|
||||||
|
songSchema.index({ 's3File.hasS3File': 1 });
|
||||||
|
songSchema.index({ location: 1 });
|
||||||
|
|
||||||
export const Song = mongoose.model('Song', songSchema);
|
export const Song = mongoose.model('Song', songSchema);
|
||||||
@ -104,12 +104,11 @@ router.post('/link/:musicFileId/:songId', async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Song not found' });
|
return res.status(404).json({ error: 'Song not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
musicFile.songId = song._id;
|
await matchingService.linkMusicFileToSong(musicFile, song);
|
||||||
await musicFile.save();
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Music file linked to song successfully',
|
message: 'Music file linked to song successfully',
|
||||||
musicFile: await musicFile.populate('songId')
|
song: await song.populate('s3File.musicFileId')
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error linking music file to song:', error);
|
console.error('Error linking music file to song:', error);
|
||||||
@ -120,19 +119,18 @@ router.post('/link/:musicFileId/:songId', async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* Unlink a music file from a song
|
* Unlink a music file from a song
|
||||||
*/
|
*/
|
||||||
router.delete('/unlink/:musicFileId', async (req, res) => {
|
router.delete('/unlink/:songId', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const musicFile = await MusicFile.findById(req.params.musicFileId);
|
const song = await Song.findById(req.params.songId);
|
||||||
if (!musicFile) {
|
if (!song) {
|
||||||
return res.status(404).json({ error: 'Music file not found' });
|
return res.status(404).json({ error: 'Song not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
musicFile.songId = undefined;
|
await matchingService.unlinkMusicFileFromSong(song);
|
||||||
await musicFile.save();
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Music file unlinked from song successfully',
|
message: 'Music file unlinked from song successfully',
|
||||||
musicFile
|
song
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error unlinking music file from song:', error);
|
console.error('Error unlinking music file from song:', error);
|
||||||
@ -149,12 +147,14 @@ router.get('/stats', async (req, res) => {
|
|||||||
unmatchedMusicFiles,
|
unmatchedMusicFiles,
|
||||||
matchedMusicFiles,
|
matchedMusicFiles,
|
||||||
songsWithoutMusicFiles,
|
songsWithoutMusicFiles,
|
||||||
|
songsWithMusicFiles,
|
||||||
totalSongs,
|
totalSongs,
|
||||||
totalMusicFiles
|
totalMusicFiles
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
matchingService.getUnmatchedMusicFiles(),
|
matchingService.getUnmatchedMusicFiles(),
|
||||||
matchingService.getMatchedMusicFiles(),
|
matchingService.getMatchedMusicFiles(),
|
||||||
matchingService.getSongsWithoutMusicFiles(),
|
matchingService.getSongsWithoutMusicFiles(),
|
||||||
|
matchingService.getSongsWithMusicFiles(),
|
||||||
Song.countDocuments(),
|
Song.countDocuments(),
|
||||||
MusicFile.countDocuments()
|
MusicFile.countDocuments()
|
||||||
]);
|
]);
|
||||||
@ -166,6 +166,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
matchedMusicFiles: matchedMusicFiles.length,
|
matchedMusicFiles: matchedMusicFiles.length,
|
||||||
unmatchedMusicFiles: unmatchedMusicFiles.length,
|
unmatchedMusicFiles: unmatchedMusicFiles.length,
|
||||||
songsWithoutMusicFiles: songsWithoutMusicFiles.length,
|
songsWithoutMusicFiles: songsWithoutMusicFiles.length,
|
||||||
|
songsWithMusicFiles: songsWithMusicFiles.length,
|
||||||
matchRate: totalMusicFiles > 0 ? (matchedMusicFiles.length / totalMusicFiles * 100).toFixed(1) : '0'
|
matchRate: totalMusicFiles > 0 ? (matchedMusicFiles.length / totalMusicFiles * 100).toFixed(1) : '0'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -249,13 +250,12 @@ router.get('/songs-without-music-files', async (req, res) => {
|
|||||||
const limit = parseInt(req.query.limit as string) || 20;
|
const limit = parseInt(req.query.limit as string) || 20;
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const songsWithFiles = await MusicFile.distinct('songId');
|
|
||||||
const [songs, total] = await Promise.all([
|
const [songs, total] = await Promise.all([
|
||||||
Song.find({ _id: { $nin: songsWithFiles } })
|
Song.find({ 's3File.hasS3File': { $ne: true } })
|
||||||
.sort({ title: 1 })
|
.sort({ title: 1 })
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(limit),
|
.limit(limit),
|
||||||
Song.countDocuments({ _id: { $nin: songsWithFiles } })
|
Song.countDocuments({ 's3File.hasS3File': { $ne: true } })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@ -273,4 +273,37 @@ router.get('/songs-without-music-files', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get songs with music files
|
||||||
|
*/
|
||||||
|
router.get('/songs-with-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 [songs, total] = await Promise.all([
|
||||||
|
Song.find({ 's3File.hasS3File': true })
|
||||||
|
.populate('s3File.musicFileId')
|
||||||
|
.sort({ title: 1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit),
|
||||||
|
Song.countDocuments({ 's3File.hasS3File': true })
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
songs,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / limit)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting songs with music files:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get songs with music files' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export { router as matchingRouter };
|
export { router as matchingRouter };
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import { Song } from '../models/Song.js';
|
import { Song } from '../models/Song.js';
|
||||||
import { Playlist } from '../models/Playlist.js';
|
import { Playlist } from '../models/Playlist.js';
|
||||||
import { MusicFile } from '../models/MusicFile.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@ -37,24 +36,13 @@ router.get('/', async (req: Request, res: Response) => {
|
|||||||
.sort({ title: 1 })
|
.sort({ title: 1 })
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
.populate('s3File.musicFileId')
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
// Get music file information for these songs
|
console.log(`Found ${songs.length} songs (${totalSongs} total), ${songs.filter((s: any) => s.s3File?.hasS3File).length} with S3 files`);
|
||||||
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({
|
res.json({
|
||||||
songs: songsWithMusicFiles,
|
songs,
|
||||||
pagination: {
|
pagination: {
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
@ -175,24 +163,13 @@ router.get('/playlist/*', async (req: Request, res: Response) => {
|
|||||||
.sort({ title: 1 })
|
.sort({ title: 1 })
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
.populate('s3File.musicFileId')
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
// Get music file information for these songs
|
console.log(`Found ${songs.length} songs for playlist "${playlistName}" (${totalSongs} total), ${songs.filter((s: any) => s.s3File?.hasS3File).length} with S3 files`);
|
||||||
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({
|
res.json({
|
||||||
songs: songsWithMusicFiles,
|
songs,
|
||||||
pagination: {
|
pagination: {
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@ -103,8 +103,7 @@ export class SongMatchingService {
|
|||||||
|
|
||||||
if (matches.length > 0 && matches[0].confidence >= minConfidence) {
|
if (matches.length > 0 && matches[0].confidence >= minConfidence) {
|
||||||
// Link the music file to the best match
|
// Link the music file to the best match
|
||||||
musicFile.songId = matches[0].song._id;
|
await this.linkMusicFileToSong(musicFile, matches[0].song);
|
||||||
await musicFile.save();
|
|
||||||
linked++;
|
linked++;
|
||||||
} else {
|
} else {
|
||||||
unmatched++;
|
unmatched++;
|
||||||
@ -114,6 +113,51 @@ export class SongMatchingService {
|
|||||||
return { linked, unmatched };
|
return { linked, unmatched };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link a music file to a song (preserves original location)
|
||||||
|
*/
|
||||||
|
async linkMusicFileToSong(musicFile: any, song: any): Promise<void> {
|
||||||
|
// Update the song with S3 file information
|
||||||
|
song.s3File = {
|
||||||
|
musicFileId: musicFile._id,
|
||||||
|
s3Key: musicFile.s3Key,
|
||||||
|
s3Url: musicFile.s3Url,
|
||||||
|
streamingUrl: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${musicFile.s3Key}`,
|
||||||
|
hasS3File: true
|
||||||
|
};
|
||||||
|
|
||||||
|
await song.save();
|
||||||
|
|
||||||
|
// Also update the music file to reference the song
|
||||||
|
musicFile.songId = song._id;
|
||||||
|
await musicFile.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink a music file from a song
|
||||||
|
*/
|
||||||
|
async unlinkMusicFileFromSong(song: any): Promise<void> {
|
||||||
|
// Remove S3 file information from song
|
||||||
|
song.s3File = {
|
||||||
|
musicFileId: null,
|
||||||
|
s3Key: null,
|
||||||
|
s3Url: null,
|
||||||
|
streamingUrl: null,
|
||||||
|
hasS3File: false
|
||||||
|
};
|
||||||
|
|
||||||
|
await song.save();
|
||||||
|
|
||||||
|
// Remove song reference from music file
|
||||||
|
if (song.s3File?.musicFileId) {
|
||||||
|
const musicFile = await MusicFile.findById(song.s3File.musicFileId);
|
||||||
|
if (musicFile) {
|
||||||
|
musicFile.songId = undefined;
|
||||||
|
await musicFile.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate match confidence between a music file and a song
|
* Calculate match confidence between a music file and a song
|
||||||
*/
|
*/
|
||||||
@ -156,6 +200,14 @@ export class SongMatchingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6. Original location match (if available)
|
||||||
|
if (song.location) {
|
||||||
|
const locationScore = this.matchLocation(musicFile.originalName, song.location);
|
||||||
|
if (locationScore.score > 0) {
|
||||||
|
scores.push(locationScore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate weighted average score
|
// Calculate weighted average score
|
||||||
const totalScore = scores.reduce((sum, s) => sum + s.score, 0);
|
const totalScore = scores.reduce((sum, s) => sum + s.score, 0);
|
||||||
const averageScore = scores.length > 0 ? totalScore / scores.length : 0;
|
const averageScore = scores.length > 0 ? totalScore / scores.length : 0;
|
||||||
@ -221,6 +273,32 @@ export class SongMatchingService {
|
|||||||
return { score: 0, reason: '' };
|
return { score: 0, reason: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match original location to filename
|
||||||
|
*/
|
||||||
|
private matchLocation(filename: string, location: string): { score: number; reason: string } {
|
||||||
|
if (!filename || !location) return { score: 0, reason: '' };
|
||||||
|
|
||||||
|
const cleanFilename = this.cleanString(filename);
|
||||||
|
const cleanLocation = this.cleanString(location);
|
||||||
|
|
||||||
|
// Extract filename from location path
|
||||||
|
const locationFilename = cleanLocation.split('/').pop() || cleanLocation;
|
||||||
|
const locationFilenameNoExt = locationFilename.replace(/\.[^/.]+$/, '');
|
||||||
|
|
||||||
|
// Exact filename match
|
||||||
|
if (cleanFilename.includes(locationFilenameNoExt) || locationFilenameNoExt.includes(cleanFilename.replace(/\.[^/.]+$/, ''))) {
|
||||||
|
return { score: 0.9, reason: 'Original location filename match' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path contains filename
|
||||||
|
if (cleanLocation.includes(cleanFilename.replace(/\.[^/.]+$/, ''))) {
|
||||||
|
return { score: 0.7, reason: 'Original location contains filename' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { score: 0, reason: '' };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Match title
|
* Match title
|
||||||
*/
|
*/
|
||||||
@ -390,7 +468,13 @@ export class SongMatchingService {
|
|||||||
* Get songs without music files
|
* Get songs without music files
|
||||||
*/
|
*/
|
||||||
async getSongsWithoutMusicFiles(): Promise<any[]> {
|
async getSongsWithoutMusicFiles(): Promise<any[]> {
|
||||||
const songsWithFiles = await MusicFile.distinct('songId');
|
return await Song.find({ 's3File.hasS3File': { $ne: true } });
|
||||||
return await Song.find({ _id: { $nin: songsWithFiles } });
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get songs with music files
|
||||||
|
*/
|
||||||
|
async getSongsWithMusicFiles(): Promise<any[]> {
|
||||||
|
return await Song.find({ 's3File.hasS3File': true }).populate('s3File.musicFileId');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -53,12 +53,18 @@ export const streamToXml = async (res: any) => {
|
|||||||
const songs = await Song.find({})
|
const songs = await Song.find({})
|
||||||
.skip(processedSongs)
|
.skip(processedSongs)
|
||||||
.limit(batchSize)
|
.limit(batchSize)
|
||||||
|
.populate('s3File.musicFileId')
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
// Include ALL track attributes like master collection
|
// Include ALL track attributes like master collection
|
||||||
res.write(`\n <TRACK TrackID="${song.id}" Name="${escapeXml(song.title || '')}" Artist="${escapeXml(song.artist || '')}" Composer="${escapeXml(song.composer || '')}" Album="${escapeXml(song.album || '')}" Grouping="${escapeXml(song.grouping || '')}" Genre="${escapeXml(song.genre || '')}" Kind="${escapeXml(song.kind || '')}" Size="${song.size || ''}" TotalTime="${song.totalTime || ''}" DiscNumber="${song.discNumber || ''}" TrackNumber="${song.trackNumber || ''}" Year="${song.year || ''}" AverageBpm="${song.averageBpm || ''}" DateAdded="${song.dateAdded || ''}" BitRate="${song.bitRate || ''}" SampleRate="${song.sampleRate || ''}" Comments="${escapeXml(song.comments || '')}" PlayCount="${song.playCount || ''}" Rating="${song.rating || ''}" Location="${escapeXml(song.location || '')}" Remixer="${escapeXml(song.remixer || '')}" Tonality="${escapeXml(song.tonality || '')}" Label="${escapeXml(song.label || '')}" Mix="${escapeXml(song.mix || '')}">`);
|
res.write(`\n <TRACK TrackID="${song.id}" Name="${escapeXml(song.title || '')}" Artist="${escapeXml(song.artist || '')}" Composer="${escapeXml(song.composer || '')}" Album="${escapeXml(song.album || '')}" Grouping="${escapeXml(song.grouping || '')}" Genre="${escapeXml(song.genre || '')}" Kind="${escapeXml(song.kind || '')}" Size="${song.size || ''}" TotalTime="${song.totalTime || ''}" DiscNumber="${song.discNumber || ''}" TrackNumber="${song.trackNumber || ''}" Year="${song.year || ''}" AverageBpm="${song.averageBpm || ''}" DateAdded="${song.dateAdded || ''}" BitRate="${song.bitRate || ''}" SampleRate="${song.sampleRate || ''}" Comments="${escapeXml(song.comments || '')}" PlayCount="${song.playCount || ''}" Rating="${song.rating || ''}" Location="${escapeXml(song.location || '')}" Remixer="${escapeXml(song.remixer || '')}" Tonality="${escapeXml(song.tonality || '')}" Label="${escapeXml(song.label || '')}" Mix="${escapeXml(song.mix || '')}">`);
|
||||||
|
|
||||||
|
// Add S3 file information if available (preserves original location)
|
||||||
|
if (song.s3File?.hasS3File) {
|
||||||
|
res.write(`\n <S3_FILE S3Key="${escapeXml(song.s3File.s3Key || '')}" S3Url="${escapeXml(song.s3File.s3Url || '')}" StreamingUrl="${escapeXml(song.s3File.streamingUrl || '')}" MusicFileId="${song.s3File.musicFileId?._id || ''}"/>`);
|
||||||
|
}
|
||||||
|
|
||||||
// Add TEMPO entries if they exist
|
// Add TEMPO entries if they exist
|
||||||
if (song.tempo) {
|
if (song.tempo) {
|
||||||
res.write(`\n <TEMPO Inizio="${song.tempo.inizio}" Bpm="${song.tempo.bpm}" Metro="${song.tempo.metro}" Battito="${song.tempo.battito}"/>`);
|
res.write(`\n <TEMPO Inizio="${song.tempo.inizio}" Bpm="${song.tempo.bpm}" Metro="${song.tempo.metro}" Battito="${song.tempo.battito}"/>`);
|
||||||
@ -217,11 +223,20 @@ export const exportToXml = (songs: any[], playlists: any[]): string => {
|
|||||||
'@Comments': song.comments,
|
'@Comments': song.comments,
|
||||||
'@PlayCount': song.playCount,
|
'@PlayCount': song.playCount,
|
||||||
'@Rating': song.rating,
|
'@Rating': song.rating,
|
||||||
'@Location': song.location,
|
'@Location': song.location, // Preserve original location
|
||||||
'@Remixer': song.remixer,
|
'@Remixer': song.remixer,
|
||||||
'@Tonality': song.tonality,
|
'@Tonality': song.tonality,
|
||||||
'@Label': song.label,
|
'@Label': song.label,
|
||||||
'@Mix': song.mix,
|
'@Mix': song.mix,
|
||||||
|
// Add S3 file information if available
|
||||||
|
...(song.s3File?.hasS3File ? {
|
||||||
|
S3_FILE: {
|
||||||
|
'@S3Key': song.s3File.s3Key,
|
||||||
|
'@S3Url': song.s3File.s3Url,
|
||||||
|
'@StreamingUrl': song.s3File.streamingUrl,
|
||||||
|
'@MusicFileId': song.s3File.musicFileId?._id || song.s3File.musicFileId
|
||||||
|
}
|
||||||
|
} : {}),
|
||||||
...(song.tempo ? {
|
...(song.tempo ? {
|
||||||
TEMPO: {
|
TEMPO: {
|
||||||
'@Inizio': song.tempo.inizio,
|
'@Inizio': song.tempo.inizio,
|
||||||
|
|||||||
@ -102,11 +102,16 @@ export const SongList: React.FC<SongListProps> = ({
|
|||||||
|
|
||||||
const handlePlaySong = (song: Song, e: React.MouseEvent) => {
|
const handlePlaySong = (song: Song, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (onPlaySong && song.hasMusicFile) {
|
if (onPlaySong && (song.s3File?.hasS3File || song.hasMusicFile)) {
|
||||||
onPlaySong(song);
|
onPlaySong(song);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper function to check if song has music file
|
||||||
|
const hasMusicFile = (song: Song): boolean => {
|
||||||
|
return song.s3File?.hasS3File || song.hasMusicFile || false;
|
||||||
|
};
|
||||||
|
|
||||||
// Calculate total duration
|
// Calculate total duration
|
||||||
const totalDuration = useMemo(() => {
|
const totalDuration = useMemo(() => {
|
||||||
return filteredSongs.reduce((total, song) => {
|
return filteredSongs.reduce((total, song) => {
|
||||||
@ -119,7 +124,7 @@ export const SongList: React.FC<SongListProps> = ({
|
|||||||
|
|
||||||
// Count songs with music files
|
// Count songs with music files
|
||||||
const songsWithMusicFiles = useMemo(() => {
|
const songsWithMusicFiles = useMemo(() => {
|
||||||
return filteredSongs.filter(song => song.hasMusicFile).length;
|
return filteredSongs.filter(song => hasMusicFile(song)).length;
|
||||||
}, [filteredSongs]);
|
}, [filteredSongs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -253,7 +258,7 @@ export const SongList: React.FC<SongListProps> = ({
|
|||||||
>
|
>
|
||||||
{song.title}
|
{song.title}
|
||||||
</Text>
|
</Text>
|
||||||
{song.hasMusicFile && (
|
{hasMusicFile(song) && (
|
||||||
<Tooltip label="Has music file available for playback">
|
<Tooltip label="Has music file available for playback">
|
||||||
<Badge colorScheme="green" size="sm" variant="subtle">
|
<Badge colorScheme="green" size="sm" variant="subtle">
|
||||||
<FiMusic size={10} />
|
<FiMusic size={10} />
|
||||||
@ -267,10 +272,19 @@ export const SongList: React.FC<SongListProps> = ({
|
|||||||
>
|
>
|
||||||
{song.artist} • {formatDuration(song.totalTime)}
|
{song.artist} • {formatDuration(song.totalTime)}
|
||||||
</Text>
|
</Text>
|
||||||
|
{song.location && (
|
||||||
|
<Text
|
||||||
|
fontSize="xs"
|
||||||
|
color="gray.600"
|
||||||
|
noOfLines={1}
|
||||||
|
>
|
||||||
|
📁 {song.location}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Play Button */}
|
{/* Play Button */}
|
||||||
{song.hasMusicFile && onPlaySong && (
|
{hasMusicFile(song) && onPlaySong && (
|
||||||
<Tooltip label="Play music file">
|
<Tooltip label="Play music file">
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label="Play song"
|
aria-label="Play song"
|
||||||
|
|||||||
@ -32,7 +32,7 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { FiPlay, FiLink, FiUnlink, FiSearch, FiZap, FiMusic, FiCheck } from 'react-icons/fi';
|
import { FiPlay, FiLink, FiSearch, FiZap, FiMusic, FiCheck, FiX } from 'react-icons/fi';
|
||||||
|
|
||||||
interface MatchResult {
|
interface MatchResult {
|
||||||
song: any;
|
song: any;
|
||||||
@ -48,6 +48,7 @@ interface MatchingStats {
|
|||||||
matchedMusicFiles: number;
|
matchedMusicFiles: number;
|
||||||
unmatchedMusicFiles: number;
|
unmatchedMusicFiles: number;
|
||||||
songsWithoutMusicFiles: number;
|
songsWithoutMusicFiles: number;
|
||||||
|
songsWithMusicFiles: number;
|
||||||
matchRate: string;
|
matchRate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,6 +57,7 @@ export const SongMatching: React.FC = () => {
|
|||||||
const [unmatchedMusicFiles, setUnmatchedMusicFiles] = useState<any[]>([]);
|
const [unmatchedMusicFiles, setUnmatchedMusicFiles] = useState<any[]>([]);
|
||||||
const [matchedMusicFiles, setMatchedMusicFiles] = useState<any[]>([]);
|
const [matchedMusicFiles, setMatchedMusicFiles] = useState<any[]>([]);
|
||||||
const [songsWithoutMusicFiles, setSongsWithoutMusicFiles] = useState<any[]>([]);
|
const [songsWithoutMusicFiles, setSongsWithoutMusicFiles] = useState<any[]>([]);
|
||||||
|
const [songsWithMusicFiles, setSongsWithMusicFiles] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [autoLinking, setAutoLinking] = useState(false);
|
const [autoLinking, setAutoLinking] = useState(false);
|
||||||
const [selectedMusicFile, setSelectedMusicFile] = useState<any>(null);
|
const [selectedMusicFile, setSelectedMusicFile] = useState<any>(null);
|
||||||
@ -72,11 +74,12 @@ export const SongMatching: React.FC = () => {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const [statsRes, unmatchedRes, matchedRes, songsWithoutRes] = await Promise.all([
|
const [statsRes, unmatchedRes, matchedRes, songsWithoutRes, songsWithRes] = await Promise.all([
|
||||||
fetch('/api/matching/stats'),
|
fetch('/api/matching/stats'),
|
||||||
fetch('/api/matching/unmatched-music-files'),
|
fetch('/api/matching/unmatched-music-files'),
|
||||||
fetch('/api/matching/matched-music-files'),
|
fetch('/api/matching/matched-music-files'),
|
||||||
fetch('/api/matching/songs-without-music-files')
|
fetch('/api/matching/songs-without-music-files'),
|
||||||
|
fetch('/api/matching/songs-with-music-files')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (statsRes.ok) {
|
if (statsRes.ok) {
|
||||||
@ -98,6 +101,11 @@ export const SongMatching: React.FC = () => {
|
|||||||
const songsData = await songsWithoutRes.json();
|
const songsData = await songsWithoutRes.json();
|
||||||
setSongsWithoutMusicFiles(songsData.songs);
|
setSongsWithoutMusicFiles(songsData.songs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (songsWithRes.ok) {
|
||||||
|
const songsData = await songsWithRes.json();
|
||||||
|
setSongsWithMusicFiles(songsData.songs);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading data:', error);
|
console.error('Error loading data:', error);
|
||||||
toast({
|
toast({
|
||||||
@ -211,9 +219,9 @@ export const SongMatching: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUnlinkMusicFile = async (musicFileId: string) => {
|
const handleUnlinkMusicFile = async (songId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/matching/unlink/${musicFileId}`, {
|
const response = await fetch(`/api/matching/unlink/${songId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -312,6 +320,7 @@ export const SongMatching: React.FC = () => {
|
|||||||
<Text color="gray.600">
|
<Text color="gray.600">
|
||||||
Automatically match and link music files to songs in your Rekordbox library.
|
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.
|
This will attempt to find matches based on filename, title, artist, and other metadata.
|
||||||
|
Original file paths are preserved and S3 information is added alongside.
|
||||||
</Text>
|
</Text>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
@ -432,16 +441,21 @@ export const SongMatching: React.FC = () => {
|
|||||||
→ {musicFile.songId.title} by {musicFile.songId.artist}
|
→ {musicFile.songId.title} by {musicFile.songId.artist}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{musicFile.songId?.location && (
|
||||||
|
<Text fontSize="xs" color="gray.500">
|
||||||
|
📁 {musicFile.songId.location}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<Tooltip label="Unlink from song">
|
<Tooltip label="Unlink from song">
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label="Unlink"
|
aria-label="Unlink"
|
||||||
icon={<FiUnlink />}
|
icon={<FiX />}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
colorScheme="red"
|
colorScheme="red"
|
||||||
onClick={() => handleUnlinkMusicFile(musicFile._id)}
|
onClick={() => handleUnlinkMusicFile(musicFile.songId._id)}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label="Play music file">
|
<Tooltip label="Play music file">
|
||||||
@ -467,6 +481,86 @@ export const SongMatching: React.FC = () => {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Songs with Music Files */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Heading size="md">Songs with Music Files ({songsWithMusicFiles.length})</Heading>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
{songsWithMusicFiles.length === 0 ? (
|
||||||
|
<Text color="gray.500" textAlign="center">
|
||||||
|
No songs have music files linked yet.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<VStack spacing={3} align="stretch">
|
||||||
|
{songsWithMusicFiles.slice(0, 10).map((song) => (
|
||||||
|
<Box
|
||||||
|
key={song._id}
|
||||||
|
p={3}
|
||||||
|
border="1px"
|
||||||
|
borderColor="blue.200"
|
||||||
|
borderRadius="md"
|
||||||
|
bg="blue.50"
|
||||||
|
>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<VStack align="start" spacing={1} flex={1}>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Text fontWeight="bold" fontSize="sm">
|
||||||
|
{song.title}
|
||||||
|
</Text>
|
||||||
|
<Badge colorScheme="blue" size="sm">
|
||||||
|
<FiMusic style={{ marginRight: '4px' }} />
|
||||||
|
Has S3 File
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="xs" color="gray.600">
|
||||||
|
{song.artist}
|
||||||
|
</Text>
|
||||||
|
{song.location && (
|
||||||
|
<Text fontSize="xs" color="gray.500">
|
||||||
|
📁 {song.location}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{song.s3File?.streamingUrl && (
|
||||||
|
<Text fontSize="xs" color="green.600">
|
||||||
|
🎵 S3: {song.s3File.s3Key}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Tooltip label="Unlink music file">
|
||||||
|
<IconButton
|
||||||
|
aria-label="Unlink"
|
||||||
|
icon={<FiX />}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
colorScheme="red"
|
||||||
|
onClick={() => handleUnlinkMusicFile(song._id)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Play music file">
|
||||||
|
<IconButton
|
||||||
|
aria-label="Play"
|
||||||
|
icon={<FiPlay />}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
colorScheme="blue"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{songsWithMusicFiles.length > 10 && (
|
||||||
|
<Text fontSize="sm" color="gray.500" textAlign="center">
|
||||||
|
Showing first 10 of {songsWithMusicFiles.length} songs with music files
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Suggestions Modal */}
|
{/* Suggestions Modal */}
|
||||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
@ -510,6 +604,11 @@ export const SongMatching: React.FC = () => {
|
|||||||
{match.song.album}
|
{match.song.album}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{match.song.location && (
|
||||||
|
<Text fontSize="xs" color="gray.500">
|
||||||
|
📁 {match.song.location}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Text fontSize="xs" color="gray.500">
|
<Text fontSize="xs" color="gray.500">
|
||||||
{match.matchReason}
|
{match.matchReason}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export interface Song {
|
|||||||
comments?: string;
|
comments?: string;
|
||||||
playCount?: string;
|
playCount?: string;
|
||||||
rating?: string;
|
rating?: string;
|
||||||
location?: string;
|
location?: string; // Original file path from Rekordbox XML
|
||||||
remixer?: string;
|
remixer?: string;
|
||||||
tonality?: string;
|
tonality?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
@ -30,7 +30,26 @@ export interface Song {
|
|||||||
metro?: string;
|
metro?: string;
|
||||||
battito?: string;
|
battito?: string;
|
||||||
};
|
};
|
||||||
// Music file integration
|
// S3 file integration (preserves original location)
|
||||||
|
s3File?: {
|
||||||
|
musicFileId?: string | {
|
||||||
|
_id: string;
|
||||||
|
originalName: string;
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
album?: string;
|
||||||
|
duration?: number;
|
||||||
|
size: number;
|
||||||
|
format?: string;
|
||||||
|
s3Key: string;
|
||||||
|
s3Url: string;
|
||||||
|
};
|
||||||
|
s3Key?: string;
|
||||||
|
s3Url?: string;
|
||||||
|
streamingUrl?: string;
|
||||||
|
hasS3File: boolean;
|
||||||
|
};
|
||||||
|
// Legacy support for backward compatibility
|
||||||
hasMusicFile?: boolean;
|
hasMusicFile?: boolean;
|
||||||
musicFile?: {
|
musicFile?: {
|
||||||
_id: string;
|
_id: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user