Merge pull request #190 from kveld9/feat/playlist-csv-import

feature: spotify CSV playlist import
This commit is contained in:
Psychotoxical
2026-04-15 18:09:27 +02:00
committed by GitHub
15 changed files with 867 additions and 21 deletions
+2
View File
@@ -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;
+16
View File
@@ -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',
+16
View File
@@ -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',
+16
View File
@@ -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',
+16
View File
@@ -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',
+16
View File
@@ -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',
+18
View File
@@ -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',
+16
View File
@@ -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: 'Популярное',
+16
View File
@@ -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: '最常播放',
+1 -1
View File
@@ -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<HTMLDivElement>(null);
+680 -9
View File
@@ -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<string, string> = {
// 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<string, string>[];
// 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<string | null>(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<Set<string>>(new Set());
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(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 <T, R>(
items: T[],
batchSize: number,
processor: (item: T) => Promise<R | null>
): 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() {
>
<Search size={16} /> {t('playlists.addSongs')}
</button>
<button
className="btn btn-ghost"
onClick={handleImportCsv}
disabled={csvImporting}
data-tooltip={t('playlists.importCSVTooltip')}
>
{csvImporting ? <Loader2 size={16} className="spin-slow" /> : <FileUp size={16} />}
{t('playlists.importCSV')}
</button>
{/* search close resets selection */}
{songs.length > 0 && (
activeZip && !activeZip.done && !activeZip.error ? (
@@ -1278,6 +1774,14 @@ export default function PlaylistDetail() {
onSave={handleSaveMeta}
/>
)}
{csvImportReport && (
<CsvImportReportModal
report={csvImportReport}
playlistName={playlist?.name || 'Unknown Playlist'}
onClose={() => setCsvImportReport(null)}
/>
)}
</div>
);
}
@@ -1414,14 +1918,10 @@ function PlaylistEditModal({
</div>
</div>
<div className="playlist-edit-footer">
<label className="toggle-label" style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none' }}>
<label className="toggle-switch" style={{ marginBottom: 0 }}>
<input type="checkbox" checked={isPublic} onChange={e => setIsPublic(e.target.checked)} />
<span className="toggle-track" />
</label>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('playlists.editPublic')}</span>
</label>
<div className="modal-actions">
<button className="btn btn-surface" onClick={onClose}>
{t('playlists.editCancel')}
</button>
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !name.trim()}>
{saving ? <Loader2 size={14} className="spin-slow" /> : null}
{t('playlists.editSave')}
@@ -1431,3 +1931,174 @@ function PlaylistEditModal({
</div>
);
}
// ── 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(
<div
className="modal-overlay modal-overlay--csv"
onClick={handleOverlayClick}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 99999,
}}
>
<div
className="modal-content"
style={{
maxWidth: 500,
maxHeight: '80vh',
width: '90%',
display: 'flex',
flexDirection: 'column',
position: 'relative',
}}
onClick={e => e.stopPropagation()}
>
<button className="btn btn-ghost modal-close" onClick={onClose} style={{ position: 'absolute', top: 16, right: 16 }}>
<X size={18} />
</button>
<h2 className="modal-title" style={{ fontSize: 20, marginBottom: 16 }}>{t('playlists.csvImportReport')}</h2>
<div style={{ display: 'grid', gridTemplateColumns: report.searchErrors && report.searchErrors.length > 0 ? 'repeat(5, 1fr)' : 'repeat(4, 1fr)', gap: 12, marginBottom: 20, textAlign: 'center' }}>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text)' }}>{report.total}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportTotal')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--accent)' }}>{report.added}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportAdded')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text-muted)' }}>{report.duplicates}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportDuplicates')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: report.notFound.length > 0 ? '#ff6b6b' : 'var(--text-muted)' }}>{report.notFound.length}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportNotFound')}</div>
</div>
{report.searchErrors && report.searchErrors.length > 0 && (
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: '#ffa500' }}>{report.searchErrors.length}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportNetworkErrors')}</div>
</div>
)}
</div>
{report.duplicateTracks.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: 'var(--text-muted)' }}>{t('playlists.csvImportDuplicatesTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 150, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.duplicateTracks.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
</div>
))}
</div>
</>
)}
{report.notFound.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: 'var(--text-muted)' }}>{t('playlists.csvImportNotFoundTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 200, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.notFound.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
</div>
))}
</div>
</>
)}
{report.searchErrors && report.searchErrors.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: '#ffa500' }}>{t('playlists.csvImportNetworkErrorsTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 150, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.searchErrors.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
</div>
))}
</div>
</>
)}
<div className="modal-actions" style={{ marginTop: 'auto', display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
<button className="btn btn-surface" onClick={downloadReport}>
<Download size={14} /> {t('playlists.csvImportDownloadReport')}
</button>
<button className="btn btn-primary" onClick={onClose}>
{t('playlists.csvImportClose')}
</button>
</div>
</div>
</div>,
document.body
);
}
+10
View File
@@ -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);
+23 -7
View File
@@ -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;