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 85567ac5..e1476561 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -992,6 +992,22 @@ 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', + csvImportNoValidTracks: 'Keine gültigen Titel in CSV-Datei gefunden', + csvImportFailed: 'CSV-Import fehlgeschlagen', + csvImportToast: '{{added}} hinzugefügt, {{notFound}} nicht gefunden, {{duplicates}} Duplikate', }, mostPlayed: { title: 'Meistgehört', diff --git a/src/locales/en.ts b/src/locales/en.ts index 6d724bed..44dd8df8 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -994,6 +994,22 @@ 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', + csvImportNoValidTracks: 'No valid tracks found in CSV file', + csvImportFailed: 'Failed to import CSV file', + csvImportToast: '{{added}} added, {{notFound}} not found, {{duplicates}} duplicates', }, mostPlayed: { title: 'Most Played', diff --git a/src/locales/es.ts b/src/locales/es.ts index 0ab5197f..3714bbb8 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -995,6 +995,22 @@ 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', + 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: { title: 'Más Reproducidos', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 045e71c2..1731c21c 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -990,6 +990,22 @@ 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', + 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: { title: 'Les plus joués', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 730a6204..3435bbc1 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -989,6 +989,22 @@ 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', + csvImportNoValidTracks: 'Ingen gyldige spor funnet i CSV-filen', + csvImportFailed: 'Kunne ikke importere CSV-fil', + csvImportToast: '{{added}} lagt til, {{notFound}} ikke funnet, {{duplicates}} duplikater', }, mostPlayed: { title: 'Mest spilt', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 5042cd89..0fa6841c 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -987,6 +987,24 @@ 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', + csvImportNoValidTracks: 'Geen geldige nummers gevonden in CSV-bestand', + csvImportFailed: 'CSV-import mislukt', + csvImportToast: '{{added}} toegevoegd, {{notFound}} niet gevonden, {{duplicates}} duplicaten', }, mostPlayed: { title: 'Meest gespeeld', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 34f0b159..b6285ea2 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -1048,6 +1048,22 @@ export const ruTranslation = { addToPlaylistError: 'Ошибка при добавлении в плейлист', removeSuccess: 'Трек убран из плейлиста', removeError: 'Ошибка при удалении из плейлиста', + importCSV: 'Импорт CSV', + importCSVTooltip: 'Импорт из CSV Spotify', + csvImportReport: 'Отчёт об импорте CSV', + csvImportTotal: 'Всего', + csvImportAdded: 'Добавлено', + csvImportDuplicates: 'Дубликаты', + csvImportNotFound: 'Не найдено', + csvImportNetworkErrors: 'Ошибки сети', + csvImportDuplicatesTitle: 'Дубликаты (уже в плейлисте):', + csvImportNotFoundTitle: 'Не найденные треки:', + csvImportNetworkErrorsTitle: 'Ошибки сети (можно повторить импорт):', + csvImportDownloadReport: 'Скачать отчёт', + csvImportClose: 'Закрыть', + csvImportNoValidTracks: 'В CSV-файле не найдено действительных треков', + csvImportFailed: 'Не удалось импортировать CSV-файл', + csvImportToast: '{{added}} добавлено, {{notFound}} не найдено, {{duplicates}} дубликатов', }, mostPlayed: { title: 'Популярное', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 25ff4dea..2459f108 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -983,6 +983,22 @@ export const zhTranslation = { addToPlaylistSuccess: '{{count}} 首歌曲已添加到 {{playlist}}', addToPlaylistNoNew: '{{playlist}} 没有新歌曲', 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: { title: '最常播放', diff --git a/src/pages/Artists.tsx b/src/pages/Artists.tsx index d7f1ce38..64726816 100644 --- a/src/pages/Artists.tsx +++ b/src/pages/Artists.tsx @@ -82,7 +82,7 @@ export default function Artists() { const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); 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 [loadingMore, setLoadingMore] = useState(false); const observerTarget = useRef(null); diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 2a9265a1..df9b7923 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -1,7 +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 { @@ -19,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'; @@ -27,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 @@ -59,6 +62,148 @@ function codecLabel(song: SubsonicSong, showBitrate: boolean): string { return parts.join(' · '); } +// ── CSV Import helpers ────────────────────────────────────────────────────── +interface SpotifyCsvTrack { + trackName: string; + artistName: string; + artistNames: string[]; // Array of all artists for better matching + albumName: string; + isrc?: string; +} + +// Header mapping to canonical fields (supports English and Spanish) +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 uses commas in extended format, semicolons in simple format + 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 and parse with 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[]; + + // Verify required columns + 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; + + // Parse multiple artists from field + extract collaborators from title + 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 }, @@ -136,6 +281,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); @@ -395,6 +551,337 @@ export default function PlaylistDetail() { } }; + // ── CSV Import ────────────────────────────────────────────── + // Normalize strings for matching: remove accents, special chars, lowercase, trim + const normalizeForMatching = (s: string): string => + s.toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[ø]/gi, 'o') + .replace(/[æ]/gi, 'ae') + .trim(); + + // Clean common title suffixes (remastered, live, editions, 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(); + // Apply patterns multiple times for nested cases + 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 more changes + } + return cleaned; + }; + + // Levenshtein distance for similarity scoring + 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; + // Use normalized strings (without accents) for comparison + const dist = levenshtein(normalizeForMatching(a), normalizeForMatching(b)); + return 1 - dist / maxLen; + }; + + // Process searches in batches to avoid overloading the server + 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(t('playlists.csvImportNoValidTracks'), 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; + + // Process in batches of 10 to balance speed/server load + await processBatch(csvTracks, 10, async (track) => { + try { + // Retry: 2 attempts in case of network error + 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; + // Wait 500ms before retrying + await new Promise(r => setTimeout(r, 500)); + } + } + + if (!searchResult || searchResult.songs.length === 0) { + notFound.push(track); + return null; + } + + // Confidence scoring for each result + // Clean CSV title for fair comparison + const cleanCsvTitle = cleanTrackTitle(track.trackName); + + const scoredMatches = searchResult.songs.map(s => { + // Fast ISRC path: if both have ISRC and they match, perfect score + 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 }; + } + + // Clean the result title as well + const cleanResultTitle = cleanTrackTitle(s.title); + + const titleScore = similarityScore(cleanResultTitle, cleanCsvTitle); + // Artist scoring: maximum score against any of the CSV artists + const artistScore = s.artist + ? Math.max(...track.artistNames.map(csvArtist => + similarityScore(s.artist || '', csvArtist) + )) + : 0; + // If no album in CSV or local, use 1.0 (neutral) to avoid penalizing + const albumScore = (s.album && track.albumName) + ? similarityScore(s.album, track.albumName) + : 1.0; + + // Dynamic weight: specific titles (>4 words) → more weight to title + 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); + + // Only accept if score is >= 0.7 (70% confidence) + const bestMatch = scoredMatches[0]; + + if (bestMatch.score < 0.7) { + notFound.push({ ...track, albumName: `${track.albumName} (score: ${bestMatch.score.toFixed(2)})` }); + return null; + } + + // Check for duplicates + if (existingIds.has(bestMatch.song.id)) { + duplicateCount++; + duplicateTracks.push(track); + return null; + } + + // Check for duplicates in tracks already queued for addition + 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 { + // Network error/timeout - distinguish from "not found" + 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)` + : ''; + + // Determine toast type based on results: + // - success: all songs were added successfully + // - warning: at least one added, but some not found/duplicates + // - error: none added (all duplicates or not found) + 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( + t('playlists.csvImportToast', { added: addedSongs.length, notFound: notFound.length, duplicates: duplicateCount }) + errorMsg, + 5000, + toastVariant + ); + } catch (err) { + console.error('CSV import failed:', err); + showToast(t('playlists.csvImportFailed'), 3000, 'error'); + } finally { + setCsvImporting(false); + } + }; + // ── Remove ──────────────────────────────────────────────────── const removeSong = (idx: number) => { const prevCount = songs.length; @@ -713,6 +1200,15 @@ export default function PlaylistDetail() { > {t('playlists.addSongs')} + {/* search close resets selection */} {songs.length > 0 && ( activeZip && !activeZip.done && !activeZip.error ? ( @@ -1278,6 +1774,14 @@ export default function PlaylistDetail() { onSave={handleSaveMeta} /> )} + + {csvImportReport && ( + setCsvImportReport(null)} + /> + )} ); } @@ -1414,14 +1918,10 @@ function PlaylistEditModal({ -
- +
+
); } + +// ── CSV Import Report Modal ─────────────────────────────────────────────────── + +interface CsvReportModalProps { + report: { + added: number; + notFound: SpotifyCsvTrack[]; + duplicates: number; + duplicateTracks: SpotifyCsvTrack[]; + total: number; + searchErrors?: SpotifyCsvTrack[]; + }; + playlistName: string; + onClose: () => void; +} + +function CsvImportReportModal({ report, playlistName, onClose }: CsvReportModalProps) { + const { t } = useTranslation(); + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) onClose(); + }; + + const downloadReport = () => { + try { + const content = [ + 'CSV Import Report', + `Playlist: ${playlistName}`, + `Date: ${new Date().toLocaleString()}`, + `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.searchErrors && report.searchErrors.length > 0 ? ['Network Error Tracks (may retry):', ...report.searchErrors.map(t => `- ${t.trackName} by ${t.artistName}`), ''] : []), + ].join('\n'); + + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + + // Detailed name: playlist + date-time-seconds + const timestamp = new Date().toISOString() + .replace(/[:.]/g, '-') + .slice(0, 19); + const safePlaylistName = playlistName.replace(/[/\\?%*:|"<>]/g, '-').substring(0, 50); + a.download = `import-report-${safePlaylistName}-${timestamp}.txt`; + + a.href = url; + a.click(); + URL.revokeObjectURL(url); + + showToast('Report downloaded successfully', 3000, 'info'); + } catch (err) { + console.error('Failed to download report:', err); + showToast('Failed to download report', 3000, 'error'); + } + }; + + return createPortal( +
+
e.stopPropagation()} + > + + +

{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 557aada1..586858d6 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;