mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
Merge pull request #199 from kveld9/fix/csv-import-clean
fix: csv import improvements
This commit is contained in:
@@ -1040,6 +1040,8 @@ export const deTranslation = {
|
|||||||
csvImportNoValidTracks: 'Keine gültigen Titel in CSV-Datei gefunden',
|
csvImportNoValidTracks: 'Keine gültigen Titel in CSV-Datei gefunden',
|
||||||
csvImportFailed: 'CSV-Import fehlgeschlagen',
|
csvImportFailed: 'CSV-Import fehlgeschlagen',
|
||||||
csvImportToast: '{{added}} hinzugefügt, {{notFound}} nicht gefunden, {{duplicates}} Duplikate',
|
csvImportToast: '{{added}} hinzugefügt, {{notFound}} nicht gefunden, {{duplicates}} Duplikate',
|
||||||
|
csvImportDownloadSuccess: 'Bericht erfolgreich heruntergeladen',
|
||||||
|
csvImportDownloadError: 'Bericht konnte nicht heruntergeladen werden',
|
||||||
},
|
},
|
||||||
mostPlayed: {
|
mostPlayed: {
|
||||||
title: 'Meistgehört',
|
title: 'Meistgehört',
|
||||||
|
|||||||
@@ -1042,6 +1042,8 @@ export const enTranslation = {
|
|||||||
csvImportNoValidTracks: 'No valid tracks found in CSV file',
|
csvImportNoValidTracks: 'No valid tracks found in CSV file',
|
||||||
csvImportFailed: 'Failed to import CSV file',
|
csvImportFailed: 'Failed to import CSV file',
|
||||||
csvImportToast: '{{added}} added, {{notFound}} not found, {{duplicates}} duplicates',
|
csvImportToast: '{{added}} added, {{notFound}} not found, {{duplicates}} duplicates',
|
||||||
|
csvImportDownloadSuccess: 'Report downloaded successfully',
|
||||||
|
csvImportDownloadError: 'Failed to download report',
|
||||||
},
|
},
|
||||||
mostPlayed: {
|
mostPlayed: {
|
||||||
title: 'Most Played',
|
title: 'Most Played',
|
||||||
|
|||||||
@@ -1043,6 +1043,8 @@ export const esTranslation = {
|
|||||||
csvImportNoValidTracks: 'No se encontraron canciones válidas en el archivo CSV',
|
csvImportNoValidTracks: 'No se encontraron canciones válidas en el archivo CSV',
|
||||||
csvImportFailed: 'Error al importar el archivo CSV',
|
csvImportFailed: 'Error al importar el archivo CSV',
|
||||||
csvImportToast: '{{added}} agregadas, {{notFound}} no encontradas, {{duplicates}} duplicadas',
|
csvImportToast: '{{added}} agregadas, {{notFound}} no encontradas, {{duplicates}} duplicadas',
|
||||||
|
csvImportDownloadSuccess: 'Reporte descargado exitosamente',
|
||||||
|
csvImportDownloadError: 'Error al descargar el reporte',
|
||||||
},
|
},
|
||||||
mostPlayed: {
|
mostPlayed: {
|
||||||
title: 'Más Reproducidos',
|
title: 'Más Reproducidos',
|
||||||
|
|||||||
@@ -1038,6 +1038,8 @@ export const frTranslation = {
|
|||||||
csvImportNoValidTracks: 'Aucun titre valide trouvé dans le fichier CSV',
|
csvImportNoValidTracks: 'Aucun titre valide trouvé dans le fichier CSV',
|
||||||
csvImportFailed: 'Échec de l\'import CSV',
|
csvImportFailed: 'Échec de l\'import CSV',
|
||||||
csvImportToast: '{{added}} ajoutés, {{notFound}} non trouvés, {{duplicates}} doublons',
|
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: {
|
mostPlayed: {
|
||||||
title: 'Les plus joués',
|
title: 'Les plus joués',
|
||||||
|
|||||||
@@ -1037,6 +1037,8 @@ export const nbTranslation = {
|
|||||||
csvImportNoValidTracks: 'Ingen gyldige spor funnet i CSV-filen',
|
csvImportNoValidTracks: 'Ingen gyldige spor funnet i CSV-filen',
|
||||||
csvImportFailed: 'Kunne ikke importere CSV-fil',
|
csvImportFailed: 'Kunne ikke importere CSV-fil',
|
||||||
csvImportToast: '{{added}} lagt til, {{notFound}} ikke funnet, {{duplicates}} duplikater',
|
csvImportToast: '{{added}} lagt til, {{notFound}} ikke funnet, {{duplicates}} duplikater',
|
||||||
|
csvImportDownloadSuccess: 'Rapport lastet ned',
|
||||||
|
csvImportDownloadError: 'Kunne ikke laste ned rapport',
|
||||||
},
|
},
|
||||||
mostPlayed: {
|
mostPlayed: {
|
||||||
title: 'Mest spilt',
|
title: 'Mest spilt',
|
||||||
|
|||||||
@@ -1037,6 +1037,8 @@ export const nlTranslation = {
|
|||||||
csvImportNoValidTracks: 'Geen geldige nummers gevonden in CSV-bestand',
|
csvImportNoValidTracks: 'Geen geldige nummers gevonden in CSV-bestand',
|
||||||
csvImportFailed: 'CSV-import mislukt',
|
csvImportFailed: 'CSV-import mislukt',
|
||||||
csvImportToast: '{{added}} toegevoegd, {{notFound}} niet gevonden, {{duplicates}} duplicaten',
|
csvImportToast: '{{added}} toegevoegd, {{notFound}} niet gevonden, {{duplicates}} duplicaten',
|
||||||
|
csvImportDownloadSuccess: 'Rapport succesvol gedownload',
|
||||||
|
csvImportDownloadError: 'Rapport downloaden mislukt',
|
||||||
},
|
},
|
||||||
mostPlayed: {
|
mostPlayed: {
|
||||||
title: 'Meest gespeeld',
|
title: 'Meest gespeeld',
|
||||||
|
|||||||
@@ -1096,6 +1096,8 @@ export const ruTranslation = {
|
|||||||
csvImportNoValidTracks: 'В CSV-файле не найдено действительных треков',
|
csvImportNoValidTracks: 'В CSV-файле не найдено действительных треков',
|
||||||
csvImportFailed: 'Не удалось импортировать CSV-файл',
|
csvImportFailed: 'Не удалось импортировать CSV-файл',
|
||||||
csvImportToast: '{{added}} добавлено, {{notFound}} не найдено, {{duplicates}} дубликатов',
|
csvImportToast: '{{added}} добавлено, {{notFound}} не найдено, {{duplicates}} дубликатов',
|
||||||
|
csvImportDownloadSuccess: 'Отчёт успешно скачан',
|
||||||
|
csvImportDownloadError: 'Не удалось скачать отчёт',
|
||||||
},
|
},
|
||||||
mostPlayed: {
|
mostPlayed: {
|
||||||
title: 'Популярное',
|
title: 'Популярное',
|
||||||
|
|||||||
@@ -1031,6 +1031,8 @@ export const zhTranslation = {
|
|||||||
csvImportNoValidTracks: 'CSV 文件中未找到有效曲目',
|
csvImportNoValidTracks: 'CSV 文件中未找到有效曲目',
|
||||||
csvImportFailed: 'CSV 文件导入失败',
|
csvImportFailed: 'CSV 文件导入失败',
|
||||||
csvImportToast: '{{added}} 已添加,{{notFound}} 未找到,{{duplicates}} 重复项',
|
csvImportToast: '{{added}} 已添加,{{notFound}} 未找到,{{duplicates}} 重复项',
|
||||||
|
csvImportDownloadSuccess: '报告下载成功',
|
||||||
|
csvImportDownloadError: '报告下载失败',
|
||||||
},
|
},
|
||||||
mostPlayed: {
|
mostPlayed: {
|
||||||
title: '最常播放',
|
title: '最常播放',
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ interface SpotifyCsvTrack {
|
|||||||
artistNames: string[]; // Array of all artists for better matching
|
artistNames: string[]; // Array of all artists for better matching
|
||||||
albumName: string;
|
albumName: string;
|
||||||
isrc?: 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)
|
// Header mapping to canonical fields (supports English and Spanish)
|
||||||
@@ -651,6 +653,29 @@ export default function PlaylistDetail() {
|
|||||||
return 1 - dist / maxLen;
|
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
|
// Process searches in batches to avoid overloading the server
|
||||||
const processBatch = async <T, R>(
|
const processBatch = async <T, R>(
|
||||||
items: T[],
|
items: T[],
|
||||||
@@ -703,9 +728,12 @@ export default function PlaylistDetail() {
|
|||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const maxAttempts = 2;
|
const maxAttempts = 2;
|
||||||
|
|
||||||
|
// Clean title before search to find matches despite version suffixes
|
||||||
|
const cleanTitleForSearch = cleanTrackTitle(track.trackName);
|
||||||
|
|
||||||
while (attempts < maxAttempts) {
|
while (attempts < maxAttempts) {
|
||||||
try {
|
try {
|
||||||
searchResult = await search(track.trackName, { songCount: 40, artistCount: 0, albumCount: 0 });
|
searchResult = await search(cleanTitleForSearch, { songCount: 40, artistCount: 0, albumCount: 0 });
|
||||||
break;
|
break;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
attempts++;
|
attempts++;
|
||||||
@@ -716,7 +744,11 @@ export default function PlaylistDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!searchResult || searchResult.songs.length === 0) {
|
if (!searchResult || searchResult.songs.length === 0) {
|
||||||
notFound.push(track);
|
notFound.push({
|
||||||
|
...track,
|
||||||
|
score: 0,
|
||||||
|
thresholdNeeded: 0.6, // Minimum threshold, nothing to compare
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -756,11 +788,19 @@ export default function PlaylistDetail() {
|
|||||||
return { song: s, score: totalScore, titleScore, artistScore, albumScore, isrcMatch: false };
|
return { song: s, score: totalScore, titleScore, artistScore, albumScore, isrcMatch: false };
|
||||||
}).sort((a, b) => b.score - a.score);
|
}).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 bestMatch = scoredMatches[0];
|
||||||
|
const secondMatch = scoredMatches[1];
|
||||||
|
const titleWords = cleanCsvTitle.split(/\s+/).length;
|
||||||
|
|
||||||
if (bestMatch.score < 0.7) {
|
const threshold = calculateDynamicThreshold(bestMatch, secondMatch, titleWords);
|
||||||
notFound.push({ ...track, albumName: `${track.albumName} (score: ${bestMatch.score.toFixed(2)})` });
|
|
||||||
|
if (bestMatch.score < threshold) {
|
||||||
|
notFound.push({
|
||||||
|
...track,
|
||||||
|
score: bestMatch.score,
|
||||||
|
thresholdNeeded: threshold,
|
||||||
|
});
|
||||||
return null;
|
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}` : ''}`,
|
`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.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}`), ''] : []),
|
...(report.searchErrors && report.searchErrors.length > 0 ? ['Network Error Tracks (may retry):', ...report.searchErrors.map(t => `- ${t.trackName} by ${t.artistName}`), ''] : []),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
@@ -1956,10 +1996,10 @@ function CsvImportReportModal({ report, playlistName, onClose }: CsvReportModalP
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
showToast('Report downloaded successfully', 3000, 'info');
|
showToast(t('playlists.csvImportDownloadSuccess'), 3000, 'info');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to download report:', 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
|
|||||||
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
|
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
|
||||||
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
|
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
|
||||||
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
|
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
|
||||||
|
{track.albumName && <div style={{ color: 'var(--text-muted)', fontSize: 11 }}>{track.albumName}</div>}
|
||||||
|
{track.score !== undefined && (
|
||||||
|
<div style={{ fontSize: 11, marginTop: 2 }}>
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>Score: </span>
|
||||||
|
<span style={{ color: track.score >= (track.thresholdNeeded ?? 0.6) ? '#4ade80' : '#f87171' }}>
|
||||||
|
{track.score.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}> (threshold: {(track.thresholdNeeded ?? 0.6).toFixed(2)})</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user