some fixes

This commit is contained in:
kveld9
2026-04-15 11:50:44 -03:00
parent a44581e5d6
commit fc90617e9c
10 changed files with 68 additions and 31 deletions
+3
View File
@@ -999,6 +999,9 @@ export const deTranslation = {
csvImportNetworkErrorsTitle: 'Netzwerkfehler (Import kann wiederholt werden):', csvImportNetworkErrorsTitle: 'Netzwerkfehler (Import kann wiederholt werden):',
csvImportDownloadReport: 'Bericht herunterladen', csvImportDownloadReport: 'Bericht herunterladen',
csvImportClose: 'Schließen', csvImportClose: 'Schließen',
csvImportNoValidTracks: 'Keine gültigen Titel in CSV-Datei gefunden',
csvImportFailed: 'CSV-Import fehlgeschlagen',
csvImportToast: '{{added}} hinzugefügt, {{notFound}} nicht gefunden, {{duplicates}} Duplikate',
}, },
mostPlayed: { mostPlayed: {
title: 'Meistgehört', title: 'Meistgehört',
+3
View File
@@ -1001,6 +1001,9 @@ export const enTranslation = {
csvImportNetworkErrorsTitle: 'Network Errors (may retry import):', csvImportNetworkErrorsTitle: 'Network Errors (may retry import):',
csvImportDownloadReport: 'Download Report', csvImportDownloadReport: 'Download Report',
csvImportClose: 'Close', csvImportClose: 'Close',
csvImportNoValidTracks: 'No valid tracks found in CSV file',
csvImportFailed: 'Failed to import CSV file',
csvImportToast: '{{added}} added, {{notFound}} not found, {{duplicates}} duplicates',
}, },
mostPlayed: { mostPlayed: {
title: 'Most Played', title: 'Most Played',
+3
View File
@@ -1002,6 +1002,9 @@ export const esTranslation = {
csvImportNetworkErrorsTitle: 'Errores de Red (puede reintentar la importación):', csvImportNetworkErrorsTitle: 'Errores de Red (puede reintentar la importación):',
csvImportDownloadReport: 'Descargar Reporte', csvImportDownloadReport: 'Descargar Reporte',
csvImportClose: 'Cerrar', csvImportClose: 'Cerrar',
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',
}, },
mostPlayed: { mostPlayed: {
title: 'Más Reproducidos', title: 'Más Reproducidos',
+3
View File
@@ -997,6 +997,9 @@ export const frTranslation = {
csvImportNetworkErrorsTitle: 'Erreurs Réseau (peut réessayer l\'import):', csvImportNetworkErrorsTitle: 'Erreurs Réseau (peut réessayer l\'import):',
csvImportDownloadReport: 'Télécharger le Rapport', csvImportDownloadReport: 'Télécharger le Rapport',
csvImportClose: 'Fermer', csvImportClose: 'Fermer',
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',
}, },
mostPlayed: { mostPlayed: {
title: 'Les plus joués', title: 'Les plus joués',
+3
View File
@@ -996,6 +996,9 @@ export const nbTranslation = {
csvImportNetworkErrorsTitle: 'Nettverksfeil (kan prøve import på nytt):', csvImportNetworkErrorsTitle: 'Nettverksfeil (kan prøve import på nytt):',
csvImportDownloadReport: 'Last ned rapport', csvImportDownloadReport: 'Last ned rapport',
csvImportClose: 'Lukk', csvImportClose: 'Lukk',
csvImportNoValidTracks: 'Ingen gyldige spor funnet i CSV-filen',
csvImportFailed: 'Kunne ikke importere CSV-fil',
csvImportToast: '{{added}} lagt til, {{notFound}} ikke funnet, {{duplicates}} duplikater',
}, },
mostPlayed: { mostPlayed: {
title: 'Mest spilt', title: 'Mest spilt',
+3
View File
@@ -996,6 +996,9 @@ export const nlTranslation = {
csvImportNetworkErrorsTitle: 'Netwerkfouten (import kan opnieuw proberen):', csvImportNetworkErrorsTitle: 'Netwerkfouten (import kan opnieuw proberen):',
csvImportDownloadReport: 'Rapport downloaden', csvImportDownloadReport: 'Rapport downloaden',
csvImportClose: 'Sluiten', csvImportClose: 'Sluiten',
csvImportNoValidTracks: 'Geen geldige nummers gevonden in CSV-bestand',
csvImportFailed: 'CSV-import mislukt',
csvImportToast: '{{added}} toegevoegd, {{notFound}} niet gevonden, {{duplicates}} duplicaten',
}, },
mostPlayed: { mostPlayed: {
title: 'Meest gespeeld', title: 'Meest gespeeld',
+3
View File
@@ -1055,6 +1055,9 @@ export const ruTranslation = {
csvImportNetworkErrorsTitle: 'Ошибки сети (можно повторить импорт):', csvImportNetworkErrorsTitle: 'Ошибки сети (можно повторить импорт):',
csvImportDownloadReport: 'Скачать отчёт', csvImportDownloadReport: 'Скачать отчёт',
csvImportClose: 'Закрыть', csvImportClose: 'Закрыть',
csvImportNoValidTracks: 'В CSV-файле не найдено действительных треков',
csvImportFailed: 'Не удалось импортировать CSV-файл',
csvImportToast: '{{added}} добавлено, {{notFound}} не найдено, {{duplicates}} дубликатов',
}, },
mostPlayed: { mostPlayed: {
title: 'Популярное', title: 'Популярное',
+16
View File
@@ -977,6 +977,22 @@ export const zhTranslation = {
addToPlaylistSuccess: '{{count}} 首歌曲已添加到 {{playlist}}', addToPlaylistSuccess: '{{count}} 首歌曲已添加到 {{playlist}}',
addToPlaylistNoNew: '{{playlist}} 没有新歌曲', addToPlaylistNoNew: '{{playlist}} 没有新歌曲',
addToPlaylistError: '添加到播放列表时出错', addToPlaylistError: '添加到播放列表时出错',
importCSV: '导入 CSV',
importCSVTooltip: '从 Spotify CSV 导入',
csvImportReport: 'CSV 导入报告',
csvImportTotal: '总计',
csvImportAdded: '已添加',
csvImportDuplicates: '重复项',
csvImportNotFound: '未找到',
csvImportNetworkErrors: '网络错误',
csvImportDuplicatesTitle: '重复曲目(已在播放列表中):',
csvImportNotFoundTitle: '未找到的曲目:',
csvImportNetworkErrorsTitle: '网络错误(可重试导入):',
csvImportDownloadReport: '下载报告',
csvImportClose: '关闭',
csvImportNoValidTracks: 'CSV 文件中未找到有效曲目',
csvImportFailed: 'CSV 文件导入失败',
csvImportToast: '{{added}} 已添加,{{notFound}} 未找到,{{duplicates}} 重复项',
}, },
mostPlayed: { mostPlayed: {
title: '最常播放', title: '最常播放',
+1 -1
View File
@@ -82,7 +82,7 @@ export default function Artists() {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const showArtistImages = useAuthStore(s => s.showArtistImages); const showArtistImages = useAuthStore(s => s.showArtistImages);
const PAGE_SIZE = showArtistImages ? 50 : 100; // Menor con imágenes para reducir I/O const PAGE_SIZE = showArtistImages ? 50 : 100; // Smaller with images to reduce I/O
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const observerTarget = useRef<HTMLDivElement>(null); const observerTarget = useRef<HTMLDivElement>(null);
+30 -30
View File
@@ -61,12 +61,12 @@ function codecLabel(song: SubsonicSong, showBitrate: boolean): string {
interface SpotifyCsvTrack { interface SpotifyCsvTrack {
trackName: string; trackName: string;
artistName: string; artistName: string;
artistNames: string[]; // Array de todos los artistas para mejor matching artistNames: string[]; // Array of all artists for better matching
albumName: string; albumName: string;
isrc?: string; isrc?: string;
} }
// Mapeo de headers a campos canónicos (soporta inglés y español) // Header mapping to canonical fields (supports English and Spanish)
const HEADER_MAPPINGS: Record<string, string> = { const HEADER_MAPPINGS: Record<string, string> = {
// Track name // Track name
'track name': 'trackName', 'track name': 'trackName',
@@ -145,7 +145,7 @@ function extractFeaturedArtists(title: string): string[] {
} }
function parseSpotifyCsv(csvContent: string): SpotifyCsvTrack[] { function parseSpotifyCsv(csvContent: string): SpotifyCsvTrack[] {
// Strip BOM y parsear con Papa Parse // Strip BOM and parse with Papa Parse
const cleanContent = csvContent.replace(/^\uFEFF/, ''); const cleanContent = csvContent.replace(/^\uFEFF/, '');
const parseResult = Papa.parse(cleanContent, { const parseResult = Papa.parse(cleanContent, {
@@ -163,7 +163,7 @@ function parseSpotifyCsv(csvContent: string): SpotifyCsvTrack[] {
const data = parseResult.data as Record<string, string>[]; const data = parseResult.data as Record<string, string>[];
// Verificar columnas requeridas // Verify required columns
if (!data.length || !data[0].trackName || !data[0].artistName) { if (!data.length || !data[0].trackName || !data[0].artistName) {
console.error('CSV columns not found. Available headers:', Object.keys(data[0] || {})); console.error('CSV columns not found. Available headers:', Object.keys(data[0] || {}));
return []; return [];
@@ -181,7 +181,7 @@ function parseSpotifyCsv(csvContent: string): SpotifyCsvTrack[] {
if (!trackName || !artistField) continue; if (!trackName || !artistField) continue;
// Parsear múltiples artistas del campo + extraer colaboradores del título // Parse multiple artists from field + extract collaborators from title
const artistNames = parseArtists(artistField); const artistNames = parseArtists(artistField);
const featuredArtists = extractFeaturedArtists(trackName); const featuredArtists = extractFeaturedArtists(trackName);
const allArtists = [...new Set([...artistNames, ...featuredArtists])]; const allArtists = [...new Set([...artistNames, ...featuredArtists])];
@@ -508,7 +508,7 @@ export default function PlaylistDetail() {
}; };
// ── CSV Import ────────────────────────────────────────────── // ── CSV Import ──────────────────────────────────────────────
// Normalizar strings para matching: quita tildes, caracteres especiales, lowercase, trim // Normalize strings for matching: remove accents, special chars, lowercase, trim
const normalizeForMatching = (s: string): string => const normalizeForMatching = (s: string): string =>
s.toLowerCase() s.toLowerCase()
.normalize('NFD') .normalize('NFD')
@@ -517,7 +517,7 @@ export default function PlaylistDetail() {
.replace(/[æ]/gi, 'ae') .replace(/[æ]/gi, 'ae')
.trim(); .trim();
// Limpia sufijos comunes de títulos (remasterizados, en vivo, ediciones, etc.) // Clean common title suffixes (remastered, live, editions, etc.)
const cleanTrackTitle = (title: string): string => { const cleanTrackTitle = (title: string): string => {
const suffixes = [ const suffixes = [
// Remastered variants // Remastered variants
@@ -612,19 +612,19 @@ export default function PlaylistDetail() {
/\s*\(\d{4}\)$/i, /\s*\(\d{4}\)$/i,
]; ];
let cleaned = title.trim(); let cleaned = title.trim();
// Aplicar patrones múltiples veces para casos anidados // Apply patterns multiple times for nested cases
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
const previous = cleaned; const previous = cleaned;
for (const regex of suffixes) { for (const regex of suffixes) {
cleaned = cleaned.replace(regex, ''); cleaned = cleaned.replace(regex, '');
} }
cleaned = cleaned.trim(); cleaned = cleaned.trim();
if (previous === cleaned) break; // No más cambios if (previous === cleaned) break; // No more changes
} }
return cleaned; return cleaned;
}; };
// Distancia de Levenshtein para scoring de similitud // Levenshtein distance for similarity scoring
const levenshtein = (a: string, b: string): number => { const levenshtein = (a: string, b: string): number => {
const matrix: number[][] = []; const matrix: number[][] = [];
for (let i = 0; i <= b.length; i++) matrix[i] = [i]; for (let i = 0; i <= b.length; i++) matrix[i] = [i];
@@ -642,12 +642,12 @@ export default function PlaylistDetail() {
const similarityScore = (a: string, b: string): number => { const similarityScore = (a: string, b: string): number => {
const maxLen = Math.max(a.length, b.length); const maxLen = Math.max(a.length, b.length);
if (maxLen === 0) return 1; if (maxLen === 0) return 1;
// Usar strings normalizados (sin tildes) para comparación // Use normalized strings (without accents) for comparison
const dist = levenshtein(normalizeForMatching(a), normalizeForMatching(b)); const dist = levenshtein(normalizeForMatching(a), normalizeForMatching(b));
return 1 - dist / maxLen; return 1 - dist / maxLen;
}; };
// Procesa búsquedas en batches para no saturar el servidor // Process searches in batches to avoid overloading the server
const processBatch = async <T, R>( const processBatch = async <T, R>(
items: T[], items: T[],
batchSize: number, batchSize: number,
@@ -679,7 +679,7 @@ export default function PlaylistDetail() {
const csvTracks = parseSpotifyCsv(content); const csvTracks = parseSpotifyCsv(content);
if (csvTracks.length === 0) { if (csvTracks.length === 0) {
showToast('No valid tracks found in CSV file', 3000, 'error'); showToast(t('playlists.csvImportNoValidTracks'), 3000, 'error');
setCsvImporting(false); setCsvImporting(false);
return; return;
} }
@@ -691,10 +691,10 @@ export default function PlaylistDetail() {
const duplicateTracks: SpotifyCsvTrack[] = []; const duplicateTracks: SpotifyCsvTrack[] = [];
let duplicateCount = 0; let duplicateCount = 0;
// Procesar en batches de 10 para balance velocidad/servidor // Process in batches of 10 to balance speed/server load
await processBatch(csvTracks, 10, async (track) => { await processBatch(csvTracks, 10, async (track) => {
try { try {
// Retry: 2 intentos en caso de error de red // Retry: 2 attempts in case of network error
let searchResult; let searchResult;
let attempts = 0; let attempts = 0;
const maxAttempts = 2; const maxAttempts = 2;
@@ -706,7 +706,7 @@ export default function PlaylistDetail() {
} catch (err) { } catch (err) {
attempts++; attempts++;
if (attempts >= maxAttempts) throw err; if (attempts >= maxAttempts) throw err;
// Esperar 500ms antes de reintentar // Wait 500ms before retrying
await new Promise(r => setTimeout(r, 500)); await new Promise(r => setTimeout(r, 500));
} }
} }
@@ -716,32 +716,32 @@ export default function PlaylistDetail() {
return null; return null;
} }
// Scoring de confianza para cada resultado // Confidence scoring for each result
// Limpiar título del CSV para comparación justa // Clean CSV title for fair comparison
const cleanCsvTitle = cleanTrackTitle(track.trackName); const cleanCsvTitle = cleanTrackTitle(track.trackName);
const scoredMatches = searchResult.songs.map(s => { const scoredMatches = searchResult.songs.map(s => {
// Vía rápida ISRC: si ambos tienen ISRC y coinciden, score perfecto // Fast ISRC path: if both have ISRC and they match, perfect score
if (track.isrc && s.isrc && track.isrc.toUpperCase() === s.isrc.toUpperCase()) { if (track.isrc && s.isrc && track.isrc.toUpperCase() === s.isrc.toUpperCase()) {
return { song: s, score: 1.0, titleScore: 1.0, artistScore: 1.0, isrcMatch: true }; return { song: s, score: 1.0, titleScore: 1.0, artistScore: 1.0, isrcMatch: true };
} }
// Limpiar título del resultado también // Clean the result title as well
const cleanResultTitle = cleanTrackTitle(s.title); const cleanResultTitle = cleanTrackTitle(s.title);
const titleScore = similarityScore(cleanResultTitle, cleanCsvTitle); const titleScore = similarityScore(cleanResultTitle, cleanCsvTitle);
// Scoring de artista: máximo score contra cualquiera de los artistas del CSV // Artist scoring: maximum score against any of the CSV artists
const artistScore = s.artist const artistScore = s.artist
? Math.max(...track.artistNames.map(csvArtist => ? Math.max(...track.artistNames.map(csvArtist =>
similarityScore(s.artist || '', csvArtist) similarityScore(s.artist || '', csvArtist)
)) ))
: 0; : 0;
// Si no hay álbum en CSV o en local, usar 1.0 (neutral) para no penalizar // If no album in CSV or local, use 1.0 (neutral) to avoid penalizing
const albumScore = (s.album && track.albumName) const albumScore = (s.album && track.albumName)
? similarityScore(s.album, track.albumName) ? similarityScore(s.album, track.albumName)
: 1.0; : 1.0;
// Peso dinámico: títulos específicos (>4 palabras) → más peso al título // Dynamic weight: specific titles (>4 words) → more weight to title
const titleWords = cleanCsvTitle.split(/\s+/).length; const titleWords = cleanCsvTitle.split(/\s+/).length;
const isSpecificTitle = titleWords > 4; const isSpecificTitle = titleWords > 4;
const titleWeight = isSpecificTitle ? 0.55 : 0.4; const titleWeight = isSpecificTitle ? 0.55 : 0.4;
@@ -809,10 +809,10 @@ export default function PlaylistDetail() {
? ` (${searchErrors.length} network errors - may retry)` ? ` (${searchErrors.length} network errors - may retry)`
: ''; : '';
// Determinar tipo de toast según resultados: // Determine toast type based on results:
// - success: todas las canciones fueron añadidas correctamente // - success: all songs were added successfully
// - warning: al menos una añadida, pero algunas no se encontraron/duplicadas // - warning: at least one added, but some not found/duplicates
// - error: ninguna añadida (todas duplicadas o no encontradas) // - error: none added (all duplicates or not found)
const hasAdded = addedSongs.length > 0; const hasAdded = addedSongs.length > 0;
const hasIssues = notFound.length > 0 || duplicateCount > 0 || searchErrors.length > 0; const hasIssues = notFound.length > 0 || duplicateCount > 0 || searchErrors.length > 0;
@@ -826,13 +826,13 @@ export default function PlaylistDetail() {
} }
showToast( showToast(
`${addedSongs.length} added, ${notFound.length} not found, ${duplicateCount} duplicates${errorMsg}`, t('playlists.csvImportToast', { added: addedSongs.length, notFound: notFound.length, duplicates: duplicateCount }) + errorMsg,
5000, 5000,
toastVariant toastVariant
); );
} catch (err) { } catch (err) {
console.error('CSV import failed:', err); console.error('CSV import failed:', err);
showToast('Failed to import CSV file', 3000, 'error'); showToast(t('playlists.csvImportFailed'), 3000, 'error');
} finally { } finally {
setCsvImporting(false); setCsvImporting(false);
} }
@@ -1999,7 +1999,7 @@ function CsvImportReportModal({ report, playlistName, onClose }: CsvReportModalP
</> </>
)} )}
<div className="modal-actions" style={{ marginTop: 'auto' }}> <div className="modal-actions" style={{ marginTop: 'auto', display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
<button className="btn btn-surface" onClick={downloadReport}> <button className="btn btn-surface" onClick={downloadReport}>
<Download size={14} /> {t('playlists.csvImportDownloadReport')} <Download size={14} /> {t('playlists.csvImportDownloadReport')}
</button> </button>