From a44581e5d6a33c5677f36e4a73eb777ebfdc795c Mon Sep 17 00:00:00 2001 From: kveld9 Date: Wed, 15 Apr 2026 11:19:31 -0300 Subject: [PATCH] new feature: import spotify playlist with .csv --- package-lock.json | 23 +- package.json | 2 + src/api/subsonic.ts | 2 + src/locales/de.ts | 13 + src/locales/en.ts | 13 + src/locales/es.ts | 13 + src/locales/fr.ts | 13 + src/locales/nb.ts | 13 + src/locales/nl.ts | 15 + src/locales/ru.ts | 13 + src/pages/PlaylistDetail.tsx | 690 ++++++++++++++++++++++++++++++++++- src/styles/components.css | 10 + src/utils/toast.ts | 30 +- 13 files changed, 830 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 53a92c03..d2d91d58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "psysonic", - "version": "1.34.11-dev", + "version": "1.34.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "psysonic", - "version": "1.34.11-dev", + "version": "1.34.11", "dependencies": { "@fontsource-variable/dm-sans": "^5.2.8", "@fontsource-variable/figtree": "^5.2.10", @@ -28,10 +28,12 @@ "@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-updater": "^2.10.0", "@tauri-apps/plugin-window-state": "^2.4.1", + "@types/papaparse": "^5.5.2", "axios": "^1.7.7", "i18next": "^25.8.16", "lucide-react": "^0.462.0", "md5": "^2.3.0", + "papaparse": "^5.5.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^16.5.6", @@ -1757,12 +1759,20 @@ "version": "25.3.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" } }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -2639,6 +2649,12 @@ ], "license": "MIT" }, + "node_modules/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2955,7 +2971,6 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { diff --git a/package.json b/package.json index 4fff8159..8c808261 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,12 @@ "@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-updater": "^2.10.0", "@tauri-apps/plugin-window-state": "^2.4.1", + "@types/papaparse": "^5.5.2", "axios": "^1.7.7", "i18next": "^25.8.16", "lucide-react": "^0.462.0", "md5": "^2.3.0", + "papaparse": "^5.5.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^16.5.6", diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index bf80a6aa..8a22a699 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -110,6 +110,8 @@ export interface SubsonicSong { genre?: string; path?: string; albumArtist?: string; + /** ISRC code when available (e.g., Navidrome) */ + isrc?: string; replayGain?: { trackGain?: number; albumGain?: number; diff --git a/src/locales/de.ts b/src/locales/de.ts index d7b04ab2..d66ef415 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -986,6 +986,19 @@ export const deTranslation = { addToPlaylistError: 'Fehler beim Hinzufügen zur Playlist', removeSuccess: 'Song aus Playlist entfernt', removeError: 'Fehler beim Entfernen aus der Playlist', + importCSV: 'CSV importieren', + importCSVTooltip: 'Aus Spotify-CSV importieren', + csvImportReport: 'CSV-Importbericht', + csvImportTotal: 'Gesamt', + csvImportAdded: 'Hinzugefügt', + csvImportDuplicates: 'Duplikate', + csvImportNotFound: 'Nicht gefunden', + csvImportNetworkErrors: 'Netzwerkfehler', + csvImportDuplicatesTitle: 'Doppelte Titel (bereits in Playlist):', + csvImportNotFoundTitle: 'Nicht gefundene Titel:', + csvImportNetworkErrorsTitle: 'Netzwerkfehler (Import kann wiederholt werden):', + csvImportDownloadReport: 'Bericht herunterladen', + csvImportClose: 'Schließen', }, mostPlayed: { title: 'Meistgehört', diff --git a/src/locales/en.ts b/src/locales/en.ts index d4de77cd..5ed3e481 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -988,6 +988,19 @@ export const enTranslation = { addToPlaylistError: 'Error adding to playlist', removeSuccess: 'Song removed from playlist', removeError: 'Error removing song from playlist', + importCSV: 'Import CSV', + importCSVTooltip: 'Import from Spotify CSV', + csvImportReport: 'CSV Import Report', + csvImportTotal: 'Total', + csvImportAdded: 'Added', + csvImportDuplicates: 'Duplicates', + csvImportNotFound: 'Not Found', + csvImportNetworkErrors: 'Network Errors', + csvImportDuplicatesTitle: 'Duplicate Tracks (Already in Playlist):', + csvImportNotFoundTitle: 'Tracks Not Found:', + csvImportNetworkErrorsTitle: 'Network Errors (may retry import):', + csvImportDownloadReport: 'Download Report', + csvImportClose: 'Close', }, mostPlayed: { title: 'Most Played', diff --git a/src/locales/es.ts b/src/locales/es.ts index 743c8132..ff51c9f9 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -989,6 +989,19 @@ export const esTranslation = { addToPlaylistError: 'Error al agregar a la lista', removeSuccess: 'Canción quitada de la playlist', removeError: 'Error al quitar la canción de la playlist', + importCSV: 'Importar CSV', + importCSVTooltip: 'Importar desde CSV de Spotify', + csvImportReport: 'Reporte de Importación CSV', + csvImportTotal: 'Total', + csvImportAdded: 'Agregadas', + csvImportDuplicates: 'Duplicadas', + csvImportNotFound: 'No Encontradas', + csvImportNetworkErrors: 'Errores de Red', + csvImportDuplicatesTitle: 'Canciones Duplicadas (Ya en la Lista):', + csvImportNotFoundTitle: 'Canciones No Encontradas:', + csvImportNetworkErrorsTitle: 'Errores de Red (puede reintentar la importación):', + csvImportDownloadReport: 'Descargar Reporte', + csvImportClose: 'Cerrar', }, mostPlayed: { title: 'Más Reproducidos', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index b5fa9858..45d358d6 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -984,6 +984,19 @@ export const frTranslation = { addToPlaylistError: 'Erreur lors de l\'ajout à la playlist', removeSuccess: 'Morceau retiré de la playlist', removeError: 'Erreur lors du retrait de la playlist', + importCSV: 'Importer CSV', + importCSVTooltip: 'Importer depuis CSV Spotify', + csvImportReport: 'Rapport d\'Import CSV', + csvImportTotal: 'Total', + csvImportAdded: 'Ajoutés', + csvImportDuplicates: 'Doublons', + csvImportNotFound: 'Non Trouvés', + csvImportNetworkErrors: 'Erreurs Réseau', + csvImportDuplicatesTitle: 'Titres en Doublon (Déjà dans la Playlist):', + csvImportNotFoundTitle: 'Titres Non Trouvés:', + csvImportNetworkErrorsTitle: 'Erreurs Réseau (peut réessayer l\'import):', + csvImportDownloadReport: 'Télécharger le Rapport', + csvImportClose: 'Fermer', }, mostPlayed: { title: 'Les plus joués', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 0a3b13e3..7c41fffc 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -983,6 +983,19 @@ export const nbTranslation = { addToPlaylistError: 'Feil ved å legge til i spilleliste', removeSuccess: 'Sang fjernet fra spilleliste', removeError: 'Feil ved fjerning fra spilleliste', + importCSV: 'Importer CSV', + importCSVTooltip: 'Importer fra Spotify CSV', + csvImportReport: 'CSV-importrapport', + csvImportTotal: 'Totalt', + csvImportAdded: 'Lagt til', + csvImportDuplicates: 'Duplikater', + csvImportNotFound: 'Ikke funnet', + csvImportNetworkErrors: 'Nettverksfeil', + csvImportDuplicatesTitle: 'Duplikater (allerede i spillelisten):', + csvImportNotFoundTitle: 'Ikke funnet spor:', + csvImportNetworkErrorsTitle: 'Nettverksfeil (kan prøve import på nytt):', + csvImportDownloadReport: 'Last ned rapport', + csvImportClose: 'Lukk', }, mostPlayed: { title: 'Mest spilt', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 2af79694..6745d453 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -981,6 +981,21 @@ export const nlTranslation = { addToPlaylistSuccess: '{{count}} nummers toegevoegd aan {{playlist}}', addToPlaylistNoNew: 'Geen nieuwe nummers voor {{playlist}}', addToPlaylistError: 'Fout bij toevoegen aan playlist', + removeSuccess: 'Nummer verwijderd van playlist', + removeError: 'Fout bij verwijderen uit playlist', + importCSV: 'CSV Importeren', + importCSVTooltip: 'Importeren vanuit Spotify CSV', + csvImportReport: 'CSV-importrapport', + csvImportTotal: 'Totaal', + csvImportAdded: 'Toegevoegd', + csvImportDuplicates: 'Duplicaten', + csvImportNotFound: 'Niet Gevonden', + csvImportNetworkErrors: 'Netwerkfouten', + csvImportDuplicatesTitle: 'Dubbele nummers (al in playlist):', + csvImportNotFoundTitle: 'Niet gevonden nummers:', + csvImportNetworkErrorsTitle: 'Netwerkfouten (import kan opnieuw proberen):', + csvImportDownloadReport: 'Rapport downloaden', + csvImportClose: 'Sluiten', }, mostPlayed: { title: 'Meest gespeeld', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 6619c3df..18808ce5 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -1042,6 +1042,19 @@ export const ruTranslation = { addToPlaylistError: 'Ошибка при добавлении в плейлист', removeSuccess: 'Трек убран из плейлиста', removeError: 'Ошибка при удалении из плейлиста', + importCSV: 'Импорт CSV', + importCSVTooltip: 'Импорт из CSV Spotify', + csvImportReport: 'Отчёт об импорте CSV', + csvImportTotal: 'Всего', + csvImportAdded: 'Добавлено', + csvImportDuplicates: 'Дубликаты', + csvImportNotFound: 'Не найдено', + csvImportNetworkErrors: 'Ошибки сети', + csvImportDuplicatesTitle: 'Дубликаты (уже в плейлисте):', + csvImportNotFoundTitle: 'Не найденные треки:', + csvImportNetworkErrorsTitle: 'Ошибки сети (можно повторить импорт):', + csvImportDownloadReport: 'Скачать отчёт', + csvImportClose: 'Закрыть', }, mostPlayed: { title: 'Популярное', diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 90017df8..c30eb269 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; +import { createPortal } from 'react-dom'; import { useParams, useNavigate } from 'react-router-dom'; -import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download } from 'lucide-react'; +import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download, FileUp } from 'lucide-react'; import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns'; import { AddToPlaylistSubmenu } from '../components/ContextMenu'; import { @@ -18,6 +19,8 @@ import { useThemeStore } from '../store/themeStore'; import { useDownloadModalStore } from '../store/downloadModalStore'; import { invoke } from '@tauri-apps/api/core'; import { join } from '@tauri-apps/api/path'; +import { open as openDialog } from '@tauri-apps/plugin-dialog'; +import { readTextFile } from '@tauri-apps/plugin-fs'; import { useZipDownloadStore } from '../store/zipDownloadStore'; import { useDragDrop } from '../contexts/DragDropContext'; import CachedImage, { useCachedUrl } from '../components/CachedImage'; @@ -26,6 +29,7 @@ import { useTranslation } from 'react-i18next'; import { showToast } from '../utils/toast'; import { formatHumanHoursMinutes } from '../utils/formatHumanDuration'; import StarRating from '../components/StarRating'; +import Papa from 'papaparse'; function sanitizeFilename(name: string): string { return name @@ -53,6 +57,148 @@ function codecLabel(song: SubsonicSong, showBitrate: boolean): string { return parts.join(' · '); } +// ── CSV Import helpers ────────────────────────────────────────────────────── +interface SpotifyCsvTrack { + trackName: string; + artistName: string; + artistNames: string[]; // Array de todos los artistas para mejor matching + albumName: string; + isrc?: string; +} + +// Mapeo de headers a campos canónicos (soporta inglés y español) +const HEADER_MAPPINGS: Record = { + // Track name + 'track name': 'trackName', + 'track name(s)': 'trackName', + 'track': 'trackName', + 'name': 'trackName', + 'nombre de la cancion': 'trackName', + 'nombre de cancion': 'trackName', + 'nombre de la canci\u00f3n': 'trackName', + 'nombre cancion': 'trackName', + 't\u00edtulo': 'trackName', + 'titulo': 'trackName', + // Artist name + 'artist name': 'artistName', + 'artist name(s)': 'artistName', + 'artists name': 'artistName', + 'artists name(s)': 'artistName', + 'artist': 'artistName', + 'artists': 'artistName', + 'nombre del artista': 'artistName', + 'nombres del artista': 'artistName', + 'nombre artista': 'artistName', + 'artista': 'artistName', + // Album name + 'album name': 'albumName', + 'album name(s)': 'albumName', + 'album': 'albumName', + 'nombre del album': 'albumName', + 'nombre del \u00e1lbum': 'albumName', + 'nombre album': 'albumName', + // ISRC + 'isrc': 'isrc', + 'isrc code': 'isrc', + 'codigo isrc': 'isrc', + 'c\u00f3digo isrc': 'isrc', +}; + +function normalizeHeader(header: string): string { + return header + .toLowerCase() + .replace(/\(s\)/g, '') + .replace(/[()]/g, '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .trim(); +} + +function findColumnField(header: string): string | undefined { + const normalized = normalizeHeader(header); + return HEADER_MAPPINGS[normalized]; +} + +function parseArtists(artistField: string): string[] { + // Spotify usa comas en formato extendido, punto y coma en formato simple + const separator = artistField.includes(';') ? ';' : ','; + return artistField + .split(separator) + .map(a => a.trim()) + .filter(a => a.length > 0); +} + +function extractFeaturedArtists(title: string): string[] { + const patterns = [ + /\(feat\.?\s+([^)]+)\)/i, + /\(ft\.?\s+([^)]+)\)/i, + /\(featuring\s+([^)]+)\)/i, + /\(with\s+([^)]+)\)/i, + ]; + for (const regex of patterns) { + const match = title.match(regex); + if (match) { + return match[1].split(/,|\sand\s|\s&\s/).map(s => s.trim()).filter(Boolean); + } + } + return []; +} + +function parseSpotifyCsv(csvContent: string): SpotifyCsvTrack[] { + // Strip BOM y parsear con Papa Parse + const cleanContent = csvContent.replace(/^\uFEFF/, ''); + + const parseResult = Papa.parse(cleanContent, { + header: true, + skipEmptyLines: true, + transformHeader: (header: string) => { + const field = findColumnField(header); + return field || header; + }, + }); + + if (parseResult.errors.length > 0) { + console.warn('CSV parse warnings:', parseResult.errors); + } + + const data = parseResult.data as Record[]; + + // Verificar columnas requeridas + if (!data.length || !data[0].trackName || !data[0].artistName) { + console.error('CSV columns not found. Available headers:', Object.keys(data[0] || {})); + return []; + } + + console.log('CSV parsed with Papa Parse:', { + rows: data.length, + sample: data[0], + }); + + const tracks: SpotifyCsvTrack[] = []; + for (const row of data) { + const trackName = row.trackName?.trim(); + const artistField = row.artistName?.trim() || ''; + + if (!trackName || !artistField) continue; + + // Parsear múltiples artistas del campo + extraer colaboradores del título + const artistNames = parseArtists(artistField); + const featuredArtists = extractFeaturedArtists(trackName); + const allArtists = [...new Set([...artistNames, ...featuredArtists])]; + const primaryArtist = allArtists[0] || ''; + + tracks.push({ + trackName, + artistName: primaryArtist, + artistNames: allArtists, + albumName: row.albumName?.trim() || '', + isrc: row.isrc?.trim() || undefined, + }); + } + + return tracks; +} + // ── Column configuration ────────────────────────────────────────────────────── const PL_COLUMNS: readonly ColDef[] = [ { key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true }, @@ -131,6 +277,17 @@ export default function PlaylistDetail() { const [zipDownloadId, setZipDownloadId] = useState(null); const activeZip = zipDownloadId ? zipDownloads.find(d => d.id === zipDownloadId) : undefined; + // ── CSV Import ─────────────────────────────────────────────────── + const [csvImporting, setCsvImporting] = useState(false); + const [csvImportReport, setCsvImportReport] = useState<{ + added: number; + notFound: SpotifyCsvTrack[]; + duplicates: number; + duplicateTracks: SpotifyCsvTrack[]; + total: number; + searchErrors?: SpotifyCsvTrack[]; + } | null>(null); + // ── Bulk select ─────────────────────────────────────────────────── const [selectedIds, setSelectedIds] = useState>(new Set()); const [lastSelectedIdx, setLastSelectedIdx] = useState(null); @@ -350,6 +507,337 @@ export default function PlaylistDetail() { } }; + // ── CSV Import ────────────────────────────────────────────── + // Normalizar strings para matching: quita tildes, caracteres especiales, lowercase, trim + const normalizeForMatching = (s: string): string => + s.toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[ø]/gi, 'o') + .replace(/[æ]/gi, 'ae') + .trim(); + + // Limpia sufijos comunes de títulos (remasterizados, en vivo, ediciones, etc.) + const cleanTrackTitle = (title: string): string => { + const suffixes = [ + // Remastered variants + /\s*-\s*remasterizado$/i, + /\s*-\s*remaster$/i, + /\s*-\s*remastered$/i, + /\s*\(remasterizado\)$/i, + /\s*\(remaster\)$/i, + /\s*\(remastered\)$/i, + /\s*\[remasterizado\]$/i, + /\s*\[remaster\]$/i, + /\s*\[remastered\]$/i, + /\s*-\s*remasterizado\s+\d{4}$/i, + /\s*-\s*remastered\s+\d{4}$/i, + /\s*\(\d{4}\s+remaster\)$/i, + /\s*\(\d{4}\s+remastered\)$/i, + // Live variants + /\s*-\s*en vivo$/i, + /\s*-\s*live$/i, + /\s*-\s*version en vivo$/i, + /\s*-\s*studio version$/i, + /\s*-\s*version de estudio$/i, + /\s*\(en vivo\)$/i, + /\s*\(live\)$/i, + /\s*\(live .*\)$/i, + /\s*\[en vivo\]$/i, + /\s*\[live\]$/i, + /\s*\[live .*\]$/i, + /\s*-\s*live at.*$/i, + /\s*\(live at.*\)$/i, + // Version/Edition variants + /\s*-\s*version$/i, + /\s*-\s*versión$/i, + /\s*\(version\)$/i, + /\s*\(versión\)$/i, + /\s*\[version\]$/i, + /\s*\[versión\]$/i, + /\s*-\s*album version$/i, + /\s*\(album version\)$/i, + /\s*\[album version\]$/i, + // Radio/Edit variants + /\s*-\s*radio edit$/i, + /\s*-\s*radio version$/i, + /\s*\(radio edit\)$/i, + /\s*\(radio version\)$/i, + /\s*\[radio edit\]$/i, + /\s*\[radio version\]$/i, + /\s*-\s*edit$/i, + /\s*\(edit\)$/i, + /\s*\[edit\]$/i, + // Acoustic/Instrumental variants + /\s*-\s*acoustic$/i, + /\s*-\s*acústico$/i, + /\s*\(acoustic\)$/i, + /\s*\(acústico\)$/i, + /\s*\[acoustic\]$/i, + /\s*\[acústico\]$/i, + /\s*-\s*instrumental$/i, + /\s*\(instrumental\)$/i, + /\s*\[instrumental\]$/i, + // Featuring/Feat/Ft/With variants + /\s*\(feat\.?\s+.*\)$/i, + /\s*\[feat\.?\s+.*\]$/i, + /\s*-\s*feat\.?\s+.*$/i, + /\s*\(featuring\s+.*\)$/i, + /\s*\[featuring\s+.*\]$/i, + /\s*\(ft\.?\s+.*\)$/i, + /\s*\[ft\.?\s+.*\]$/i, + /\s*-\s*ft\.?\s+.*$/i, + /\s*\(with\s+.*\)$/i, + /\s*\[with\s+.*\]$/i, + /\s*-\s*with\s+.*$/i, + /\s*ft\.?\s+.*$/i, + // Explicit/Clean tags + /\s*\(explicit\)$/i, + /\s*\[explicit\]$/i, + /\s*\(clean\)$/i, + /\s*\[clean\]$/i, + // Mono/Stereo + /\s*\(mono\)$/i, + /\s*\[mono\]$/i, + /\s*\(stereo\)$/i, + /\s*\[stereo\]$/i, + // Deluxe/Special editions + /\s*-\s*deluxe$/i, + /\s*\(deluxe\)$/i, + /\s*\[deluxe\]$/i, + /\s*-\s*special edition$/i, + /\s*\(special edition\)$/i, + /\s*\[special edition\]$/i, + // Year in parentheses (common in remasters) + /\s*\(\d{4}\)$/i, + ]; + let cleaned = title.trim(); + // Aplicar patrones múltiples veces para casos anidados + for (let i = 0; i < 3; i++) { + const previous = cleaned; + for (const regex of suffixes) { + cleaned = cleaned.replace(regex, ''); + } + cleaned = cleaned.trim(); + if (previous === cleaned) break; // No más cambios + } + return cleaned; + }; + + // Distancia de Levenshtein para scoring de similitud + const levenshtein = (a: string, b: string): number => { + const matrix: number[][] = []; + for (let i = 0; i <= b.length; i++) matrix[i] = [i]; + for (let j = 0; j <= a.length; j++) matrix[0][j] = j; + for (let i = 1; i <= b.length; i++) { + for (let j = 1; j <= a.length; j++) { + matrix[i][j] = b[i - 1] === a[j - 1] + ? matrix[i - 1][j - 1] + : Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1); + } + } + return matrix[b.length][a.length]; + }; + + const similarityScore = (a: string, b: string): number => { + const maxLen = Math.max(a.length, b.length); + if (maxLen === 0) return 1; + // Usar strings normalizados (sin tildes) para comparación + const dist = levenshtein(normalizeForMatching(a), normalizeForMatching(b)); + return 1 - dist / maxLen; + }; + + // Procesa búsquedas en batches para no saturar el servidor + const processBatch = async ( + items: T[], + batchSize: number, + processor: (item: T) => Promise + ): Promise<(R | null)[]> => { + const results: (R | null)[] = []; + for (let i = 0; i < items.length; i += batchSize) { + const batch = items.slice(i, i + batchSize); + const batchResults = await Promise.all(batch.map(processor)); + results.push(...batchResults); + } + return results; + }; + + const handleImportCsv = async () => { + if (!id || csvImporting) return; + + try { + const selected = await openDialog({ + filters: [{ name: 'CSV Files', extensions: ['csv'] }], + multiple: false, + title: 'Import Spotify Playlist CSV', + }); + + if (!selected || typeof selected !== 'string') return; + + setCsvImporting(true); + const content = await readTextFile(selected); + const csvTracks = parseSpotifyCsv(content); + + if (csvTracks.length === 0) { + showToast('No valid tracks found in CSV file', 3000, 'error'); + setCsvImporting(false); + return; + } + + const existingIds = new Set(songs.map(s => s.id)); + const addedSongs: SubsonicSong[] = []; + const notFound: SpotifyCsvTrack[] = []; + const searchErrors: SpotifyCsvTrack[] = []; + const duplicateTracks: SpotifyCsvTrack[] = []; + let duplicateCount = 0; + + // Procesar en batches de 10 para balance velocidad/servidor + await processBatch(csvTracks, 10, async (track) => { + try { + // Retry: 2 intentos en caso de error de red + let searchResult; + let attempts = 0; + const maxAttempts = 2; + + while (attempts < maxAttempts) { + try { + searchResult = await search(track.trackName, { songCount: 40, artistCount: 0, albumCount: 0 }); + break; + } catch (err) { + attempts++; + if (attempts >= maxAttempts) throw err; + // Esperar 500ms antes de reintentar + await new Promise(r => setTimeout(r, 500)); + } + } + + if (!searchResult || searchResult.songs.length === 0) { + notFound.push(track); + return null; + } + + // Scoring de confianza para cada resultado + // Limpiar título del CSV para comparación justa + const cleanCsvTitle = cleanTrackTitle(track.trackName); + + const scoredMatches = searchResult.songs.map(s => { + // Vía rápida ISRC: si ambos tienen ISRC y coinciden, score perfecto + 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 }; + } + + // Limpiar título del resultado también + const cleanResultTitle = cleanTrackTitle(s.title); + + const titleScore = similarityScore(cleanResultTitle, cleanCsvTitle); + // Scoring de artista: máximo score contra cualquiera de los artistas del CSV + const artistScore = s.artist + ? Math.max(...track.artistNames.map(csvArtist => + similarityScore(s.artist || '', csvArtist) + )) + : 0; + // Si no hay álbum en CSV o en local, usar 1.0 (neutral) para no penalizar + const albumScore = (s.album && track.albumName) + ? similarityScore(s.album, track.albumName) + : 1.0; + + // Peso dinámico: títulos específicos (>4 palabras) → más peso al título + const titleWords = cleanCsvTitle.split(/\s+/).length; + const isSpecificTitle = titleWords > 4; + const titleWeight = isSpecificTitle ? 0.55 : 0.4; + const artistWeight = isSpecificTitle ? 0.25 : 0.4; + + const totalScore = artistScore * artistWeight + titleScore * titleWeight + albumScore * 0.2; + + return { song: s, score: totalScore, titleScore, artistScore, albumScore, isrcMatch: false }; + }).sort((a, b) => b.score - a.score); + + // Solo aceptar si el score es >= 0.7 (70% de confianza) + const bestMatch = scoredMatches[0]; + + if (bestMatch.score < 0.7) { + notFound.push({ ...track, albumName: `${track.albumName} (score: ${bestMatch.score.toFixed(2)})` }); + return null; + } + + // Check duplicados + if (existingIds.has(bestMatch.song.id)) { + duplicateCount++; + duplicateTracks.push(track); + return null; + } + + // Check duplicados en los que ya vamos a agregar + if (addedSongs.some(s => s.id === bestMatch.song.id)) { + duplicateCount++; + duplicateTracks.push(track); + return null; + } + + addedSongs.push(bestMatch.song); + existingIds.add(bestMatch.song.id); + return bestMatch.song; + } catch { + // Error de red/timeout - separar de "no encontrado" + searchErrors.push(track); + return null; + } + }); + + if (addedSongs.length > 0) { + const next = [...songs, ...addedSongs]; + setSongs(next); + await savePlaylist(next); + } + + // Auto-show report if there are not found tracks, duplicates, or search errors + if (notFound.length > 0 || duplicateCount > 0 || searchErrors.length > 0) { + // Small delay to let the toast appear first + setTimeout(() => { + setCsvImportReport({ + added: addedSongs.length, + notFound, + duplicates: duplicateCount, + duplicateTracks, + total: csvTracks.length, + searchErrors, + }); + }, 500); + } + + const errorMsg = searchErrors.length > 0 + ? ` (${searchErrors.length} network errors - may retry)` + : ''; + + // Determinar tipo de toast según resultados: + // - success: todas las canciones fueron añadidas correctamente + // - warning: al menos una añadida, pero algunas no se encontraron/duplicadas + // - error: ninguna añadida (todas duplicadas o no encontradas) + const hasAdded = addedSongs.length > 0; + const hasIssues = notFound.length > 0 || duplicateCount > 0 || searchErrors.length > 0; + + let toastVariant: 'success' | 'warning' | 'error'; + if (hasAdded && !hasIssues) { + toastVariant = 'success'; + } else if (hasAdded && hasIssues) { + toastVariant = 'warning'; + } else { + toastVariant = 'error'; + } + + showToast( + `${addedSongs.length} added, ${notFound.length} not found, ${duplicateCount} duplicates${errorMsg}`, + 5000, + toastVariant + ); + } catch (err) { + console.error('CSV import failed:', err); + showToast('Failed to import CSV file', 3000, 'error'); + } finally { + setCsvImporting(false); + } + }; + // ── Remove ──────────────────────────────────────────────────── const removeSong = (idx: number) => { const prevCount = songs.length; @@ -652,6 +1140,15 @@ export default function PlaylistDetail() { > {t('playlists.addSongs')} + {/* search close resets selection */} {songs.length > 0 && id && ( + +

{t('playlists.csvImportReport')}

+ +
0 ? 'repeat(5, 1fr)' : 'repeat(4, 1fr)', gap: 12, marginBottom: 20, textAlign: 'center' }}> +
+
{report.total}
+
{t('playlists.csvImportTotal')}
+
+
+
{report.added}
+
{t('playlists.csvImportAdded')}
+
+
+
{report.duplicates}
+
{t('playlists.csvImportDuplicates')}
+
+
+
0 ? '#ff6b6b' : 'var(--text-muted)' }}>{report.notFound.length}
+
{t('playlists.csvImportNotFound')}
+
+ {report.searchErrors && report.searchErrors.length > 0 && ( +
+
{report.searchErrors.length}
+
{t('playlists.csvImportNetworkErrors')}
+
+ )} +
+ + {report.duplicateTracks.length > 0 && ( + <> +

{t('playlists.csvImportDuplicatesTitle')}

+
+ {report.duplicateTracks.map((track, i) => ( +
+
{track.trackName}
+
{track.artistName}
+
+ ))} +
+ + )} + + {report.notFound.length > 0 && ( + <> +

{t('playlists.csvImportNotFoundTitle')}

+
+ {report.notFound.map((track, i) => ( +
+
{track.trackName}
+
{track.artistName}
+
+ ))} +
+ + )} + + {report.searchErrors && report.searchErrors.length > 0 && ( + <> +

{t('playlists.csvImportNetworkErrorsTitle')}

+
+ {report.searchErrors.map((track, i) => ( +
+
{track.trackName}
+
{track.artistName}
+
+ ))} +
+ + )} + +
+ + +
+ + , + document.body + ); +} diff --git a/src/styles/components.css b/src/styles/components.css index 72bebb9d..49af771f 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1984,6 +1984,16 @@ box-shadow: var(--shadow-lg); } +/* Modal CSV Import - siempre centrado */ +.modal-overlay--csv { + position: fixed !important; + inset: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + padding-top: 0 !important; +} + .modal-close { position: absolute; top: var(--space-4); diff --git a/src/utils/toast.ts b/src/utils/toast.ts index b59eafce..c7cf8e7f 100644 --- a/src/utils/toast.ts +++ b/src/utils/toast.ts @@ -20,19 +20,21 @@ function reflow(): void { } } -export type ToastVariant = 'error' | 'info'; +export type ToastVariant = 'error' | 'info' | 'warning' | 'success'; export function showToast(text: string, durationMs = 4000, variant: ToastVariant = 'info'): void { const isError = variant === 'error'; + const isWarning = variant === 'warning'; + const isSuccess = variant === 'success'; const toast = document.createElement('div'); toast.className = 'psysonic-toast'; const icon = document.createElement('span'); - icon.textContent = isError ? '✕' : 'ℹ'; + icon.textContent = isError ? '✕' : isWarning ? '!' : isSuccess ? '✓' : 'ℹ'; icon.style.cssText = ` flex-shrink: 0; - font-size: 11px; + font-size: ${isSuccess ? '10px' : '11px'}; font-weight: 700; width: 18px; height: 18px; @@ -40,7 +42,7 @@ export function showToast(text: string, durationMs = 4000, variant: ToastVariant display: flex; align-items: center; justify-content: center; - background: ${isError ? 'var(--danger)' : 'var(--accent)'}; + background: ${isError ? 'var(--danger)' : isWarning ? 'var(--warning, #f59e0b)' : isSuccess ? 'var(--success, #10b981)' : 'var(--accent)'}; color: var(--bg-app); line-height: 1; `; @@ -49,6 +51,20 @@ export function showToast(text: string, durationMs = 4000, variant: ToastVariant msg.textContent = text; msg.style.cssText = `flex: 1; min-width: 0;`; + const getBorderColor = () => { + if (isError) return 'var(--danger)'; + if (isWarning) return 'var(--warning, #f59e0b)'; + if (isSuccess) return 'var(--success, #10b981)'; + return 'var(--accent)'; + }; + + const getBoxShadow = () => { + const base = '0 4px 24px rgba(0,0,0,0.45)'; + if (isError) return `${base}, 0 0 0 1px color-mix(in srgb, var(--danger) 20%, transparent)`; + if (isWarning) return `${base}, 0 0 0 1px color-mix(in srgb, var(--warning, #f59e0b) 20%, transparent)`; + return base; + }; + toast.style.cssText = ` position: fixed; bottom: ${TOAST_BOTTOM_ANCHOR}px; @@ -59,15 +75,15 @@ export function showToast(text: string, durationMs = 4000, variant: ToastVariant gap: 10px; background: var(--bg-card); color: var(--text-primary); - border: 1px solid ${isError ? 'var(--danger)' : 'var(--border)'}; - border-left: 3px solid ${isError ? 'var(--danger)' : 'var(--accent)'}; + border: 1px solid ${getBorderColor()}; + border-left: 3px solid ${getBorderColor()}; padding: 10px 16px; border-radius: 8px; font-size: 13.5px; font-weight: 500; z-index: 999999; pointer-events: none; - box-shadow: 0 4px 24px rgba(0,0,0,0.45)${isError ? ', 0 0 0 1px color-mix(in srgb, var(--danger) 20%, transparent)' : ''}; + box-shadow: ${getBoxShadow()}; white-space: normal; word-break: break-word; transition: bottom 150ms ease;