diff --git a/src/locales/de.ts b/src/locales/de.ts index 49ac32ff..9752cc7e 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -1040,6 +1040,8 @@ export const deTranslation = { csvImportNoValidTracks: 'Keine gültigen Titel in CSV-Datei gefunden', csvImportFailed: 'CSV-Import fehlgeschlagen', csvImportToast: '{{added}} hinzugefügt, {{notFound}} nicht gefunden, {{duplicates}} Duplikate', + csvImportDownloadSuccess: 'Bericht erfolgreich heruntergeladen', + csvImportDownloadError: 'Bericht konnte nicht heruntergeladen werden', }, mostPlayed: { title: 'Meistgehört', diff --git a/src/locales/en.ts b/src/locales/en.ts index 911051e9..62bd57e2 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1042,6 +1042,8 @@ export const enTranslation = { csvImportNoValidTracks: 'No valid tracks found in CSV file', csvImportFailed: 'Failed to import CSV file', csvImportToast: '{{added}} added, {{notFound}} not found, {{duplicates}} duplicates', + csvImportDownloadSuccess: 'Report downloaded successfully', + csvImportDownloadError: 'Failed to download report', }, mostPlayed: { title: 'Most Played', diff --git a/src/locales/es.ts b/src/locales/es.ts index 57e13316..ad1e2e40 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -1043,6 +1043,8 @@ export const esTranslation = { csvImportNoValidTracks: 'No se encontraron canciones válidas en el archivo CSV', csvImportFailed: 'Error al importar el archivo CSV', csvImportToast: '{{added}} agregadas, {{notFound}} no encontradas, {{duplicates}} duplicadas', + csvImportDownloadSuccess: 'Reporte descargado exitosamente', + csvImportDownloadError: 'Error al descargar el reporte', }, mostPlayed: { title: 'Más Reproducidos', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index fb2269b5..79f997d3 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -1038,6 +1038,8 @@ export const frTranslation = { csvImportNoValidTracks: 'Aucun titre valide trouvé dans le fichier CSV', csvImportFailed: 'Échec de l\'import CSV', csvImportToast: '{{added}} ajoutés, {{notFound}} non trouvés, {{duplicates}} doublons', + csvImportDownloadSuccess: 'Rapport téléchargé avec succès', + csvImportDownloadError: 'Échec du téléchargement du rapport', }, mostPlayed: { title: 'Les plus joués', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 3049d001..079c19db 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -1037,6 +1037,8 @@ export const nbTranslation = { csvImportNoValidTracks: 'Ingen gyldige spor funnet i CSV-filen', csvImportFailed: 'Kunne ikke importere CSV-fil', csvImportToast: '{{added}} lagt til, {{notFound}} ikke funnet, {{duplicates}} duplikater', + csvImportDownloadSuccess: 'Rapport lastet ned', + csvImportDownloadError: 'Kunne ikke laste ned rapport', }, mostPlayed: { title: 'Mest spilt', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 2f93e489..dfe68021 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -1037,6 +1037,8 @@ export const nlTranslation = { csvImportNoValidTracks: 'Geen geldige nummers gevonden in CSV-bestand', csvImportFailed: 'CSV-import mislukt', csvImportToast: '{{added}} toegevoegd, {{notFound}} niet gevonden, {{duplicates}} duplicaten', + csvImportDownloadSuccess: 'Rapport succesvol gedownload', + csvImportDownloadError: 'Rapport downloaden mislukt', }, mostPlayed: { title: 'Meest gespeeld', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 830469ed..b97d3427 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -1096,6 +1096,8 @@ export const ruTranslation = { csvImportNoValidTracks: 'В CSV-файле не найдено действительных треков', csvImportFailed: 'Не удалось импортировать CSV-файл', csvImportToast: '{{added}} добавлено, {{notFound}} не найдено, {{duplicates}} дубликатов', + csvImportDownloadSuccess: 'Отчёт успешно скачан', + csvImportDownloadError: 'Не удалось скачать отчёт', }, mostPlayed: { title: 'Популярное', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index bc416374..9711380c 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -1031,6 +1031,8 @@ export const zhTranslation = { csvImportNoValidTracks: 'CSV 文件中未找到有效曲目', csvImportFailed: 'CSV 文件导入失败', csvImportToast: '{{added}} 已添加,{{notFound}} 未找到,{{duplicates}} 重复项', + csvImportDownloadSuccess: '报告下载成功', + csvImportDownloadError: '报告下载失败', }, mostPlayed: { title: '最常播放', diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 09ce9dbd..69ad6faf 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -69,6 +69,8 @@ interface SpotifyCsvTrack { artistNames: string[]; // Array of all artists for better matching albumName: string; isrc?: string; + score?: number; // Match score when track not found + thresholdNeeded?: number; // Threshold required to pass } // Header mapping to canonical fields (supports English and Spanish) @@ -651,6 +653,29 @@ export default function PlaylistDetail() { return 1 - dist / maxLen; }; + // Calculate dynamic threshold based on match quality signals + const calculateDynamicThreshold = ( + bestMatch: { score: number; artistScore: number }, + secondMatch: { score: number } | undefined, + titleWords: number + ): number => { + const baseThreshold = 0.6; // Minimum acceptable score + + // Bonus if there's a large gap between best and second match (clear winner) + const gap = secondMatch ? bestMatch.score - secondMatch.score : 0.3; + const gapBonus = gap > 0.15 ? 0.1 : gap > 0.08 ? 0.05 : 0; + + // Short titles (< 3 words) are more ambiguous, need higher threshold + // Long titles (> 4 words) are more specific, can use lower threshold + const lengthBonus = titleWords > 4 ? 0.05 : titleWords < 3 ? -0.05 : 0; + + // Strong artist match gives confidence to accept lower overall score + const artistBonus = bestMatch.artistScore > 0.85 ? 0.08 : bestMatch.artistScore > 0.7 ? 0.04 : 0; + + // Calculate final threshold, clamp between 0.55 and 0.75 + return Math.max(0.55, Math.min(0.75, baseThreshold - gapBonus - lengthBonus - artistBonus)); + }; + // Process searches in batches to avoid overloading the server const processBatch = async ( items: T[], @@ -703,9 +728,12 @@ export default function PlaylistDetail() { let attempts = 0; const maxAttempts = 2; + // Clean title before search to find matches despite version suffixes + const cleanTitleForSearch = cleanTrackTitle(track.trackName); + while (attempts < maxAttempts) { try { - searchResult = await search(track.trackName, { songCount: 40, artistCount: 0, albumCount: 0 }); + searchResult = await search(cleanTitleForSearch, { songCount: 40, artistCount: 0, albumCount: 0 }); break; } catch (err) { attempts++; @@ -716,7 +744,11 @@ export default function PlaylistDetail() { } if (!searchResult || searchResult.songs.length === 0) { - notFound.push(track); + notFound.push({ + ...track, + score: 0, + thresholdNeeded: 0.6, // Minimum threshold, nothing to compare + }); return null; } @@ -756,11 +788,19 @@ export default function PlaylistDetail() { return { song: s, score: totalScore, titleScore, artistScore, albumScore, isrcMatch: false }; }).sort((a, b) => b.score - a.score); - // Only accept if score is >= 0.7 (70% confidence) + // Use dynamic threshold based on match quality signals const bestMatch = scoredMatches[0]; + const secondMatch = scoredMatches[1]; + const titleWords = cleanCsvTitle.split(/\s+/).length; - if (bestMatch.score < 0.7) { - notFound.push({ ...track, albumName: `${track.albumName} (score: ${bestMatch.score.toFixed(2)})` }); + const threshold = calculateDynamicThreshold(bestMatch, secondMatch, titleWords); + + if (bestMatch.score < threshold) { + notFound.push({ + ...track, + score: bestMatch.score, + thresholdNeeded: threshold, + }); return null; } @@ -1937,7 +1977,7 @@ function CsvImportReportModal({ report, playlistName, onClose }: CsvReportModalP `Total: ${report.total}, Added: ${report.added}, Duplicates: ${report.duplicates}, Not Found: ${report.notFound.length}${report.searchErrors ? `, Network Errors: ${report.searchErrors.length}` : ''}`, '', ...(report.duplicateTracks.length > 0 ? ['Duplicate Tracks (skipped):', ...report.duplicateTracks.map(t => `- ${t.trackName} by ${t.artistName}${t.albumName ? ` (${t.albumName})` : ''}`), ''] : []), - ...(report.notFound.length > 0 ? ['Not Found Tracks:', ...report.notFound.map(t => `- ${t.trackName} by ${t.artistName}${t.albumName ? ` (${t.albumName})` : ''}`), ''] : []), + ...(report.notFound.length > 0 ? ['Not Found Tracks:', ...report.notFound.map(t => ` - ${t.trackName} | ${t.artistName} | ${t.albumName || 'N/A'} | Score: ${(t.score ?? 0).toFixed(2)} (threshold: ${(t.thresholdNeeded ?? 0).toFixed(2)})`), ''] : []), ...(report.searchErrors && report.searchErrors.length > 0 ? ['Network Error Tracks (may retry):', ...report.searchErrors.map(t => `- ${t.trackName} by ${t.artistName}`), ''] : []), ].join('\n'); @@ -1956,10 +1996,10 @@ function CsvImportReportModal({ report, playlistName, onClose }: CsvReportModalP a.click(); URL.revokeObjectURL(url); - showToast('Report downloaded successfully', 3000, 'info'); + showToast(t('playlists.csvImportDownloadSuccess'), 3000, 'info'); } catch (err) { console.error('Failed to download report:', err); - showToast('Failed to download report', 3000, 'error'); + showToast(t('playlists.csvImportDownloadError'), 3000, 'error'); } }; @@ -2044,6 +2084,16 @@ function CsvImportReportModal({ report, playlistName, onClose }: CsvReportModalP
{track.trackName}
{track.artistName}
+ {track.albumName &&
{track.albumName}
} + {track.score !== undefined && ( +
+ Score: + = (track.thresholdNeeded ?? 0.6) ? '#4ade80' : '#f87171' }}> + {track.score.toFixed(2)} + + (threshold: {(track.thresholdNeeded ?? 0.6).toFixed(2)}) +
+ )}
))}