diff --git a/src/components/playlist/CsvImportReportModal.tsx b/src/components/playlist/CsvImportReportModal.tsx
new file mode 100644
index 00000000..59a9c7d9
--- /dev/null
+++ b/src/components/playlist/CsvImportReportModal.tsx
@@ -0,0 +1,185 @@
+import React from 'react';
+import { createPortal } from 'react-dom';
+import { useTranslation } from 'react-i18next';
+import { Download, X } from 'lucide-react';
+import { showToast } from '../../utils/toast';
+import type { SpotifyCsvTrack } from '../../utils/spotifyCsvImport';
+
+interface CsvReportModalProps {
+ report: {
+ added: number;
+ notFound: SpotifyCsvTrack[];
+ duplicates: number;
+ duplicateTracks: SpotifyCsvTrack[];
+ total: number;
+ searchErrors?: SpotifyCsvTrack[];
+ };
+ playlistName: string;
+ onClose: () => void;
+}
+
+export default 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} | ${t.artistName} | ${t.albumName || 'N/A'} | Score: ${(t.score ?? 0).toFixed(2)} (threshold: ${(t.thresholdNeeded ?? 0).toFixed(2)})`), ''] : []),
+ ...(report.searchErrors && report.searchErrors.length > 0 ? ['Network Error Tracks (may retry):', ...report.searchErrors.map(t => `- ${t.trackName} by ${t.artistName}`), ''] : []),
+ ].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(t('playlists.csvImportDownloadSuccess'), 3000, 'info');
+ } catch (err) {
+ console.error('Failed to download report:', err);
+ showToast(t('playlists.csvImportDownloadError'), 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}
+ {track.albumName &&
{track.albumName}
}
+ {track.score !== undefined && (
+
+ Score:
+ = (track.thresholdNeeded ?? 0.6) ? '#4ade80' : '#f87171' }}>
+ {track.score.toFixed(2)}
+
+ (threshold: {(track.thresholdNeeded ?? 0.6).toFixed(2)})
+
+ )}
+
+ ))}
+
+ >
+ )}
+
+ {report.searchErrors && report.searchErrors.length > 0 && (
+ <>
+
{t('playlists.csvImportNetworkErrorsTitle')}
+
+ {report.searchErrors.map((track, i) => (
+
+
{track.trackName}
+
{track.artistName}
+
+ ))}
+
+ >
+ )}
+
+
+
+
+
+
+
,
+ document.body
+ );
+}
diff --git a/src/components/playlist/PlaylistEditModal.tsx b/src/components/playlist/PlaylistEditModal.tsx
new file mode 100644
index 00000000..e3de07ec
--- /dev/null
+++ b/src/components/playlist/PlaylistEditModal.tsx
@@ -0,0 +1,158 @@
+import React, { useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Camera, Loader2, X } from 'lucide-react';
+import type { SubsonicPlaylist } from '../../api/subsonicTypes';
+import CachedImage from '../CachedImage';
+
+interface EditModalProps {
+ playlist: SubsonicPlaylist;
+ customCoverId: string | null;
+ customCoverFetchUrl: string | null;
+ customCoverCacheKey: string | null;
+ coverQuadUrls: ({ src: string; cacheKey: string } | null)[];
+ onClose: () => void;
+ onSave: (opts: { name: string; comment: string; isPublic: boolean; coverFile: File | null; coverRemoved: boolean }) => Promise;
+}
+
+export default function PlaylistEditModal({
+ playlist, customCoverId, customCoverFetchUrl, customCoverCacheKey,
+ coverQuadUrls, onClose, onSave,
+}: EditModalProps) {
+ const { t } = useTranslation();
+ const [name, setName] = useState(playlist.name);
+ const [comment, setComment] = useState(playlist.comment ?? '');
+ const [isPublic, setIsPublic] = useState(playlist.public ?? false);
+ const [coverFile, setCoverFile] = useState(null);
+ const [coverPreview, setCoverPreview] = useState(null);
+ const [coverRemoved, setCoverRemoved] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const coverInputRef = useRef(null);
+
+ const hasExistingCover = !coverRemoved && (coverPreview || customCoverId);
+
+ const handleFileChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ e.target.value = '';
+ if (!file) return;
+ setCoverFile(file);
+ setCoverRemoved(false);
+ const reader = new FileReader();
+ reader.onload = ev => setCoverPreview(ev.target?.result as string);
+ reader.readAsDataURL(file);
+ };
+
+ const handleRemoveCover = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ setCoverFile(null);
+ setCoverPreview(null);
+ setCoverRemoved(true);
+ };
+
+ const handleSave = async () => {
+ setSaving(true);
+ try {
+ await onSave({ name, comment, isPublic, coverFile, coverRemoved });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleOverlayClick = (e: React.MouseEvent) => {
+ if (e.target === e.currentTarget) onClose();
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
+
{t('playlists.editMeta')}
+
+
+ {/* Left: cover */}
+
coverInputRef.current?.click()}
+ >
+ {coverPreview ? (
+

+ ) : !coverRemoved && customCoverFetchUrl && customCoverCacheKey ? (
+
+ ) : (
+
+ {coverQuadUrls.map((entry, i) =>
+ entry
+ ?
+ :
+ )}
+
+ )}
+
+
+
+ {hasExistingCover && (
+
+ )}
+
+
+
+
+
+ {/* Right: fields */}
+
+ setName(e.target.value)}
+ placeholder={t('playlists.editNamePlaceholder')}
+ autoFocus
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx
index 46b9ffa8..bcfd4e6f 100644
--- a/src/pages/PlaylistDetail.tsx
+++ b/src/pages/PlaylistDetail.tsx
@@ -6,7 +6,6 @@ import { getRandomSongs, filterSongsToActiveLibrary } from '../api/subsonicLibra
import type { SubsonicPlaylist, SubsonicSong } from '../api/subsonicTypes';
import { songToTrack } from '../utils/songToTrack';
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
-import { createPortal } from 'react-dom';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { ChevronDown, ChevronLeft, ChevronRight, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download, FileUp, RotateCcw, Sparkles, Square, AudioLines } from 'lucide-react';
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
@@ -30,196 +29,19 @@ import { useDragDrop } from '../contexts/DragDropContext';
import CachedImage, { useCachedUrl } from '../components/CachedImage';
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
- .replace(/[/\\?%*:|"<>]/g, '-')
- .replace(/\.{2,}/g, '.')
- .replace(/^[\s.]+|[\s.]+$/g, '')
- .substring(0, 200) || 'download';
-}
-
-function formatDuration(seconds: number): string {
- const m = Math.floor(seconds / 60);
- const s = seconds % 60;
- return `${m}:${String(s).padStart(2, '0')}`;
-}
-
-function formatSize(bytes?: number): string {
- if (!bytes) return '';
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
-}
-
-function totalDurationLabel(songs: SubsonicSong[]): string {
- const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0);
- return formatHumanHoursMinutes(total);
-}
-
-const SMART_PREFIX = 'psy-smart-';
-
-function isSmartPlaylistName(name: string): boolean {
- return (name ?? '').toLowerCase().startsWith(SMART_PREFIX);
-}
-
-function displayPlaylistName(name: string): string {
- const n = name ?? '';
- if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length);
- return n;
-}
-
-function codecLabel(song: SubsonicSong, showBitrate: boolean): string {
- const parts: string[] = [];
- if (song.suffix) parts.push(song.suffix.toUpperCase());
- if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`);
- 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;
- score?: number; // Match score when track not found
- thresholdNeeded?: number; // Threshold required to pass
-}
-
-// 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;
-}
+import {
+ sanitizeFilename,
+ formatDuration,
+ formatSize,
+ totalDurationLabel,
+ isSmartPlaylistName,
+ displayPlaylistName,
+ codecLabel,
+} from '../utils/playlistDetailHelpers';
+import { parseSpotifyCsv, type SpotifyCsvTrack } from '../utils/spotifyCsvImport';
+import PlaylistEditModal from '../components/playlist/PlaylistEditModal';
+import CsvImportReportModal from '../components/playlist/CsvImportReportModal';
// ── Column configuration ──────────────────────────────────────────────────────
const PL_COLUMNS: readonly ColDef[] = [
@@ -1937,338 +1759,3 @@ export default function PlaylistDetail() {
);
}
-// ── Playlist Edit Modal ───────────────────────────────────────────────────────
-
-interface EditModalProps {
- playlist: SubsonicPlaylist;
- customCoverId: string | null;
- customCoverFetchUrl: string | null;
- customCoverCacheKey: string | null;
- coverQuadUrls: ({ src: string; cacheKey: string } | null)[];
- onClose: () => void;
- onSave: (opts: { name: string; comment: string; isPublic: boolean; coverFile: File | null; coverRemoved: boolean }) => Promise;
-}
-
-function PlaylistEditModal({
- playlist, customCoverId, customCoverFetchUrl, customCoverCacheKey,
- coverQuadUrls, onClose, onSave,
-}: EditModalProps) {
- const { t } = useTranslation();
- const [name, setName] = useState(playlist.name);
- const [comment, setComment] = useState(playlist.comment ?? '');
- const [isPublic, setIsPublic] = useState(playlist.public ?? false);
- const [coverFile, setCoverFile] = useState(null);
- const [coverPreview, setCoverPreview] = useState(null);
- const [coverRemoved, setCoverRemoved] = useState(false);
- const [saving, setSaving] = useState(false);
- const coverInputRef = useRef(null);
-
- const hasExistingCover = !coverRemoved && (coverPreview || customCoverId);
-
- const handleFileChange = (e: React.ChangeEvent) => {
- const file = e.target.files?.[0];
- e.target.value = '';
- if (!file) return;
- setCoverFile(file);
- setCoverRemoved(false);
- const reader = new FileReader();
- reader.onload = ev => setCoverPreview(ev.target?.result as string);
- reader.readAsDataURL(file);
- };
-
- const handleRemoveCover = (e: React.MouseEvent) => {
- e.stopPropagation();
- setCoverFile(null);
- setCoverPreview(null);
- setCoverRemoved(true);
- };
-
- const handleSave = async () => {
- setSaving(true);
- try {
- await onSave({ name, comment, isPublic, coverFile, coverRemoved });
- } finally {
- setSaving(false);
- }
- };
-
- const handleOverlayClick = (e: React.MouseEvent) => {
- if (e.target === e.currentTarget) onClose();
- };
-
- return (
-
-
e.stopPropagation()}>
-
-
-
{t('playlists.editMeta')}
-
-
- {/* Left: cover */}
-
coverInputRef.current?.click()}
- >
- {coverPreview ? (
-

- ) : !coverRemoved && customCoverFetchUrl && customCoverCacheKey ? (
-
- ) : (
-
- {coverQuadUrls.map((entry, i) =>
- entry
- ?
- :
- )}
-
- )}
-
-
-
- {hasExistingCover && (
-
- )}
-
-
-
-
-
- {/* Right: fields */}
-
- setName(e.target.value)}
- placeholder={t('playlists.editNamePlaceholder')}
- autoFocus
- />
-
-
-
-
-
-
- setIsPublic(e.target.checked)} />
-
-
- {t('playlists.editPublic')}
-
-
-
-
-
-
-
-
- );
-}
-
-// ── 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} | ${t.artistName} | ${t.albumName || 'N/A'} | Score: ${(t.score ?? 0).toFixed(2)} (threshold: ${(t.thresholdNeeded ?? 0).toFixed(2)})`), ''] : []),
- ...(report.searchErrors && report.searchErrors.length > 0 ? ['Network Error Tracks (may retry):', ...report.searchErrors.map(t => `- ${t.trackName} by ${t.artistName}`), ''] : []),
- ].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(t('playlists.csvImportDownloadSuccess'), 3000, 'info');
- } catch (err) {
- console.error('Failed to download report:', err);
- showToast(t('playlists.csvImportDownloadError'), 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}
- {track.albumName &&
{track.albumName}
}
- {track.score !== undefined && (
-
- Score:
- = (track.thresholdNeeded ?? 0.6) ? '#4ade80' : '#f87171' }}>
- {track.score.toFixed(2)}
-
- (threshold: {(track.thresholdNeeded ?? 0.6).toFixed(2)})
-
- )}
-
- ))}
-
- >
- )}
-
- {report.searchErrors && report.searchErrors.length > 0 && (
- <>
-
{t('playlists.csvImportNetworkErrorsTitle')}
-
- {report.searchErrors.map((track, i) => (
-
-
{track.trackName}
-
{track.artistName}
-
- ))}
-
- >
- )}
-
-
-
-
-
-
-
,
- document.body
- );
-}
diff --git a/src/utils/playlistDetailHelpers.ts b/src/utils/playlistDetailHelpers.ts
new file mode 100644
index 00000000..0aa89033
--- /dev/null
+++ b/src/utils/playlistDetailHelpers.ts
@@ -0,0 +1,45 @@
+import type { SubsonicSong } from '../api/subsonicTypes';
+import { formatHumanHoursMinutes } from './formatHumanDuration';
+
+export function sanitizeFilename(name: string): string {
+ return name
+ .replace(/[/\\?%*:|"<>]/g, '-')
+ .replace(/\.{2,}/g, '.')
+ .replace(/^[\s.]+|[\s.]+$/g, '')
+ .substring(0, 200) || 'download';
+}
+
+export function formatDuration(seconds: number): string {
+ const m = Math.floor(seconds / 60);
+ const s = seconds % 60;
+ return `${m}:${String(s).padStart(2, '0')}`;
+}
+
+export function formatSize(bytes?: number): string {
+ if (!bytes) return '';
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+}
+
+export function totalDurationLabel(songs: SubsonicSong[]): string {
+ const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0);
+ return formatHumanHoursMinutes(total);
+}
+
+export const SMART_PREFIX = 'psy-smart-';
+
+export function isSmartPlaylistName(name: string): boolean {
+ return (name ?? '').toLowerCase().startsWith(SMART_PREFIX);
+}
+
+export function displayPlaylistName(name: string): string {
+ const n = name ?? '';
+ if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length);
+ return n;
+}
+
+export function codecLabel(song: SubsonicSong, showBitrate: boolean): string {
+ const parts: string[] = [];
+ if (song.suffix) parts.push(song.suffix.toUpperCase());
+ if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`);
+ return parts.join(' · ');
+}
diff --git a/src/utils/spotifyCsvImport.ts b/src/utils/spotifyCsvImport.ts
new file mode 100644
index 00000000..03ea014e
--- /dev/null
+++ b/src/utils/spotifyCsvImport.ts
@@ -0,0 +1,144 @@
+import Papa from 'papaparse';
+
+export interface SpotifyCsvTrack {
+ trackName: string;
+ artistName: string;
+ artistNames: string[]; // Array of all artists for better matching
+ albumName: string;
+ isrc?: string;
+ score?: number; // Match score when track not found
+ thresholdNeeded?: number; // Threshold required to pass
+}
+
+// Header mapping to canonical fields (supports English and Spanish)
+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 [];
+}
+
+export 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;
+}