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'; import { AddToPlaylistSubmenu } from '../components/ContextMenu'; import { getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt, search, setRating, star, unstar, getRandomSongs, buildDownloadUrl, filterSongsToActiveLibrary, SubsonicPlaylist, SubsonicSong, } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useShallow } from 'zustand/react/shallow'; import { usePlaylistStore } from '../store/playlistStore'; import { usePreviewStore } from '../store/previewStore'; import { useOfflineStore } from '../store/offlineStore'; import { useOfflineJobStore } from '../store/offlineJobStore'; import { useAuthStore } from '../store/authStore'; import { useThemeStore } from '../store/themeStore'; import { useDownloadModalStore } from '../store/downloadModalStore'; import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; 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'; import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic'; 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; } // ── Column configuration ────────────────────────────────────────────────────── const PL_COLUMNS: readonly ColDef[] = [ { key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true }, { key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true }, { key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false }, { key: 'album', i18nKey: 'trackAlbum', minWidth: 80, defaultWidth: 180, required: false }, { key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false }, { key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false }, { key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false }, { key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false }, { key: 'delete', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true }, ]; const PL_CENTERED = new Set(['favorite', 'rating', 'duration']); function PlaylistSearchResultThumb({ coverArt }: { coverArt: string }) { const src = useMemo(() => buildCoverArtUrl(coverArt, 40), [coverArt]); const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 40), [coverArt]); return ; } export default function PlaylistDetail() { const { id } = useParams<{ id: string }>(); const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride, userRatingOverrides } = usePlayerStore( useShallow(s => ({ playTrack: s.playTrack, enqueue: s.enqueue, openContextMenu: s.openContextMenu, currentTrack: s.currentTrack, isPlaying: s.isPlaying, starredOverrides: s.starredOverrides, setStarredOverride: s.setStarredOverride, userRatingOverrides: s.userRatingOverrides, })) ); const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior(); const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist); const { startDrag, isDragging } = useDragDrop(); const downloadPlaylist = useOfflineStore(s => s.downloadPlaylist); const deleteAlbum = useOfflineStore(s => s.deleteAlbum); const activeServerId = useAuthStore(s => s.activeServerId) ?? ''; const isDownloading = useOfflineJobStore(s => !!id && s.jobs.some(j => j.albumId === id && (j.status === 'queued' || j.status === 'downloading')) ); const isCached = useOfflineStore(s => { if (!id) return false; const meta = s.albums[`${activeServerId}:${id}`]; if (!meta || meta.trackIds.length === 0) return false; return meta.trackIds.every(tid => !!s.tracks[`${activeServerId}:${tid}`]); }); const offlineProgressDone = useOfflineJobStore(s => { if (!id) return 0; return s.jobs.filter(j => j.albumId === id && (j.status === 'done' || j.status === 'error')).length; }); const offlineProgressTotal = useOfflineJobStore(s => (!id ? 0 : s.jobs.filter(j => j.albumId === id).length)); const offlineProgress = offlineProgressTotal > 0 ? { done: offlineProgressDone, total: offlineProgressTotal } : null; const downloadFolder = useAuthStore(s => s.downloadFolder); const setDownloadFolder = useAuthStore(s => s.setDownloadFolder); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground); const enablePlaylistCoverPhoto = useThemeStore(s => s.enablePlaylistCoverPhoto); const showBitrate = useThemeStore(s => s.showBitrate); const [playlist, setPlaylist] = useState(null); const [songs, setSongs] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [ratings, setRatings] = useState>({}); const [editingMeta, setEditingMeta] = useState(false); const [customCoverId, setCustomCoverId] = useState(null); const [filterText, setFilterText] = useState(''); const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration'>('natural'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); const [sortClickCount, setSortClickCount] = useState(0); const [starredSongs, setStarredSongs] = useState>(new Set()); const [hoveredSuggestionId, setHoveredSuggestionId] = useState(null); const previewingId = usePreviewStore(s => s.previewingId); const previewAudioStarted = usePreviewStore(s => s.audioStarted); const [contextMenuSongId, setContextMenuSongId] = useState(null); const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); const zipDownloads = useZipDownloadStore(s => s.downloads); 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); const [showBulkPlPicker, setShowBulkPlPicker] = useState(false); const toggleSelect = (id: string, idx: number, shift: boolean) => { setSelectedIds(prev => { const next = new Set(prev); if (shift && lastSelectedIdx !== null) { const from = Math.min(lastSelectedIdx, idx); const to = Math.max(lastSelectedIdx, idx); songs.slice(from, to + 1).forEach(s => next.add(s.id)); } else { next.has(id) ? next.delete(id) : next.add(id); } return next; }); setLastSelectedIdx(idx); }; const allSelected = selectedIds.size === songs.length && songs.length > 0; const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id))); const bulkRemove = () => { const prevCount = songs.length; const next = songs.filter(s => !selectedIds.has(s.id)); setSongs(next); savePlaylist(next, prevCount); setSelectedIds(new Set()); }; useEffect(() => { if (!showBulkPlPicker) return; const handler = (e: MouseEvent) => { if (!(e.target as HTMLElement).closest('.bulk-pl-picker-wrap')) setShowBulkPlPicker(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [showBulkPlPicker]); // ── 2×2 cover quad (first 4 unique album covers) ───────────── const coverQuad = useMemo(() => { const seen = new Set(); const result: string[] = []; for (const s of songs) { if (s.coverArt && !seen.has(s.coverArt)) { seen.add(s.coverArt); result.push(s.coverArt); if (result.length === 4) break; } } return result; }, [songs]); // Stable fetch URLs + cache keys for the 2×2 grid and blurred background. // buildCoverArtUrl generates a new crypto salt on every call, so these MUST // be memoized — otherwise every render produces new URLs, useCachedUrl // re-triggers, state updates, another render → infinite flicker loop. const coverQuadUrls = useMemo(() => Array.from({ length: 4 }, (_, i) => { const coverId = coverQuad[i % Math.max(1, coverQuad.length)]; if (!coverId) return null; return { src: buildCoverArtUrl(coverId, 200), cacheKey: coverArtCacheKey(coverId, 200) }; }), [coverQuad]); const effectiveBgId = customCoverId ?? coverQuad[0] ?? ''; const bgFetchUrl = useMemo(() => buildCoverArtUrl(effectiveBgId, 300), [effectiveBgId]); const bgCacheKey = useMemo(() => coverArtCacheKey(effectiveBgId, 300), [effectiveBgId]); const resolvedBgUrl = useCachedUrl(bgFetchUrl, bgCacheKey); const customCoverFetchUrl = useMemo( () => customCoverId ? buildCoverArtUrl(customCoverId, 300) : null, [customCoverId], ); const customCoverCacheKey = useMemo( () => customCoverId ? coverArtCacheKey(customCoverId, 300) : null, [customCoverId], ); // Song search const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); const [searching, setSearching] = useState(false); const searchDebounce = useRef | null>(null); const [selectedSearchIds, setSelectedSearchIds] = useState>(new Set()); const [searchPlPickerOpen, setSearchPlPickerOpen] = useState(false); // Suggestions const [suggestions, setSuggestions] = useState([]); const [loadingSuggestions, setLoadingSuggestions] = useState(false); // ── Column resize/visibility ────────────────────────────────────────────── const { colVisible, visibleCols, gridStyle, startResize, toggleColumn, resetColumns, pickerOpen, setPickerOpen, pickerRef, tracklistRef, } = useTracklistColumns(PL_COLUMNS, 'psysonic_playlist_columns'); // DnD const [dropTargetIdx, setDropTargetIdx] = useState<{ idx: number; before: boolean } | null>(null); useEffect(() => { if (!contextMenuOpen) setContextMenuSongId(null); }, [contextMenuOpen]); useEffect(() => { const state = (location.state as { openEditMeta?: boolean } | null) ?? null; if (state?.openEditMeta) { setEditingMeta(true); navigate(location.pathname, { replace: true, state: null }); } }, [location.state, location.pathname, navigate]); // ── Load ───────────────────────────────────────────────────── const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined)); useEffect(() => { if (!id) return; setLoading(true); getPlaylist(id) .then(async ({ playlist, songs }) => { const filteredSongs = await filterSongsToActiveLibrary(songs); setPlaylist(playlist); setSongs(filteredSongs); if (playlist.coverArt) setCustomCoverId(playlist.coverArt); const init: Record = {}; const starred = new Set(); filteredSongs.forEach(s => { if (s.userRating) init[s.id] = s.userRating; if (s.starred) starred.add(s.id); }); setRatings(init); setStarredSongs(starred); }) .catch(() => {}) .finally(() => setLoading(false)); }, [id, lastModified]); // ── Suggestions ─────────────────────────────────────────────── const loadSuggestions = useCallback(async (currentSongs: SubsonicSong[]) => { if (!currentSongs.length) return; // Count genres across playlist songs, pick the most common one const genreCounts: Record = {}; for (const s of currentSongs) { if (s.genre) genreCounts[s.genre] = (genreCounts[s.genre] ?? 0) + 1; } const genres = Object.entries(genreCounts).sort((a, b) => b[1] - a[1]); // Fall back to no genre filter if none of the songs have genre tags const genre = genres.length > 0 ? genres[Math.floor(Math.random() * Math.min(3, genres.length))][0] : undefined; const existingIds = new Set(currentSongs.map(s => s.id)); setLoadingSuggestions(true); setSuggestions([]); try { const random = await getRandomSongs(25, genre); setSuggestions(random.filter(s => !existingIds.has(s.id)).slice(0, 10)); } catch {} setLoadingSuggestions(false); }, []); useEffect(() => { if (songs.length > 0) loadSuggestions(songs); // eslint-disable-next-line react-hooks/exhaustive-deps }, [playlist?.id]); // ── Save ────────────────────────────────────────────────────── const savePlaylist = useCallback(async (updatedSongs: SubsonicSong[], prevCount = 0) => { if (!id) return; setSaving(true); try { await updatePlaylist(id, updatedSongs.map(s => s.id), prevCount); if (id) touchPlaylist(id); } catch {} setSaving(false); }, [id, touchPlaylist]); // ── Meta edit ───────────────────────────────────────────────── const handleSaveMeta = async (opts: { name: string; comment: string; isPublic: boolean; coverFile: File | null; coverRemoved: boolean; }) => { if (!id || !playlist) return; await updatePlaylistMeta(id, opts.name.trim() || playlist.name, opts.comment, opts.isPublic); setPlaylist(p => p ? { ...p, name: opts.name.trim() || p.name, comment: opts.comment, public: opts.isPublic } : p ); if (opts.coverFile) { try { await uploadPlaylistCoverArt(id, opts.coverFile); const { playlist: refreshed } = await getPlaylist(id); setPlaylist(prev => prev ? { ...prev, coverArt: refreshed.coverArt } : prev); if (refreshed.coverArt) setCustomCoverId(refreshed.coverArt); showToast(t('playlists.coverUpdated')); } catch (err) { showToast(err instanceof Error ? err.message : t('playlists.coverUpdated'), 3000, 'error'); } } else if (opts.coverRemoved) { setCustomCoverId(null); } showToast(t('playlists.metaSaved')); setEditingMeta(false); }; // ── ZIP Download ────────────────────────────────────────────── const handleDownload = async () => { if (!playlist || !id) return; const folder = downloadFolder || await requestDownloadFolder(); if (!folder) return; const filename = `${sanitizeFilename(playlist.name)}.zip`; const destPath = await join(folder, filename); const url = buildDownloadUrl(id); const downloadId = crypto.randomUUID(); const { start, complete, fail } = useZipDownloadStore.getState(); start(downloadId, filename); setZipDownloadId(downloadId); try { await invoke('download_zip', { id: downloadId, url, destPath }); complete(downloadId); } catch (e) { fail(downloadId); console.error('ZIP download failed:', e); } }; // ── 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; }; // Calculate dynamic threshold based on match quality signals const calculateDynamicThreshold = ( bestMatch: { score: number; artistScore: number }, secondMatch: { score: number } | undefined, titleWords: number ): number => { const baseThreshold = 0.6; // Minimum acceptable score // Bonus if there's a large gap between best and second match (clear winner) const gap = secondMatch ? bestMatch.score - secondMatch.score : 0.3; const gapBonus = gap > 0.15 ? 0.1 : gap > 0.08 ? 0.05 : 0; // Short titles (< 3 words) are more ambiguous, need higher threshold // Long titles (> 4 words) are more specific, can use lower threshold const lengthBonus = titleWords > 4 ? 0.05 : titleWords < 3 ? -0.05 : 0; // Strong artist match gives confidence to accept lower overall score const artistBonus = bestMatch.artistScore > 0.85 ? 0.08 : bestMatch.artistScore > 0.7 ? 0.04 : 0; // Calculate final threshold, clamp between 0.55 and 0.75 return Math.max(0.55, Math.min(0.75, baseThreshold - gapBonus - lengthBonus - artistBonus)); }; // 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; // Clean title before search to find matches despite version suffixes const cleanTitleForSearch = cleanTrackTitle(track.trackName); while (attempts < maxAttempts) { try { searchResult = await search(cleanTitleForSearch, { 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, score: 0, thresholdNeeded: 0.6, // Minimum threshold, nothing to compare }); 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 && typeof s.isrc === 'string' && 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); // Use dynamic threshold based on match quality signals const bestMatch = scoredMatches[0]; const secondMatch = scoredMatches[1]; const titleWords = cleanCsvTitle.split(/\s+/).length; const threshold = calculateDynamicThreshold(bestMatch, secondMatch, titleWords); if (bestMatch.score < threshold) { notFound.push({ ...track, score: bestMatch.score, thresholdNeeded: threshold, }); 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 { 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; const next = songs.filter((_, i) => i !== idx); setSongs(next); savePlaylist(next, prevCount); }; // ── Add ─────────────────────────────────────────────────────── const addSong = (song: SubsonicSong) => { if (songs.some(s => s.id === song.id)) return; const scrollHost = document.querySelector('.main-content') as HTMLElement | null; const savedScroll = scrollHost?.scrollTop ?? 0; const next = [...songs, song]; setSongs(next); savePlaylist(next); setSuggestions(prev => prev.filter(s => s.id !== song.id)); setSearchResults(prev => prev.filter(s => s.id !== song.id)); if (scrollHost) { requestAnimationFrame(() => { scrollHost.scrollTop = savedScroll; }); } showToast(t('playlists.addSuccess', { count: 1, playlist: playlist?.name })); }; // ── Preview (30s mid-song sample via Rust audio engine) ──────── // Pause/resume of the main player + timer + cancel-on-supersede are all // handled in `audio_preview_play` / `audio_preview_stop`. The store mirrors // engine events so we just dispatch here and read `previewingId` for UI. const startPreview = useCallback((song: SubsonicSong) => { usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration, }, 'suggestions').catch(() => { /* engine errored — store already rolled back */ }); }, []); // Cancel any in-flight preview when the user navigates away. useEffect(() => () => { if (usePreviewStore.getState().previewingId) { usePreviewStore.getState().stopPreview(); } }, []); // ── Rating / Star ───────────────────────────────────────────── const handleRate = (songId: string, rating: number) => { setRatings(prev => ({ ...prev, [songId]: rating })); usePlayerStore.getState().setUserRatingOverride(songId, rating); setRating(songId, rating).catch(() => {}); }; const handleToggleStar = (song: SubsonicSong, e: React.MouseEvent) => { e.stopPropagation(); const isStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id); setStarredSongs(prev => { const next = new Set(prev); isStarred ? next.delete(song.id) : next.add(song.id); return next; }); setStarredOverride(song.id, !isStarred); (isStarred ? unstar(song.id, 'song') : star(song.id, 'song')).catch(() => {}); }; // ── Search ──────────────────────────────────────────────────── useEffect(() => { if (!searchOpen || !searchQuery.trim()) { setSearchResults([]); return; } if (searchDebounce.current) clearTimeout(searchDebounce.current); searchDebounce.current = setTimeout(async () => { setSearching(true); try { const res = await search(searchQuery, { songCount: 20, artistCount: 0, albumCount: 0 }); const existingIds = new Set(songs.map(s => s.id)); setSearchResults(res.songs.filter(s => !existingIds.has(s.id))); } catch {} setSearching(false); }, 350); return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); }; }, [searchQuery, searchOpen, songs]); // ── psy-drop DnD reordering ─────────────────────────────────── useEffect(() => { const container = tracklistRef.current; if (!container) return; const onPsyDrop = (e: Event) => { const detail = (e as CustomEvent).detail; if (!detail?.data) return; let parsed: any; try { parsed = JSON.parse(detail.data); } catch { return; } if (parsed.type !== 'playlist_reorder') return; setDropTargetIdx(null); const fromIdx: number = parsed.index; // Determine drop index from the event target row const target = (e.target as HTMLElement).closest('[data-track-idx]'); let toIdx = songs.length; if (target) { const targetIdx = parseInt(target.getAttribute('data-track-idx') ?? '', 10); const rect = target.getBoundingClientRect(); const cursorY = (e as CustomEvent & { clientY?: number }).clientY ?? (rect.top + rect.height / 2); const before = cursorY < rect.top + rect.height / 2; toIdx = before ? targetIdx : targetIdx + 1; } if (fromIdx === toIdx || fromIdx === toIdx - 1) return; setSongs(prev => { const next = [...prev]; const [moved] = next.splice(fromIdx, 1); const insertAt = toIdx > fromIdx ? toIdx - 1 : toIdx; next.splice(insertAt, 0, moved); savePlaylist(next); return next; }); }; container.addEventListener('psy-drop', onPsyDrop); return () => container.removeEventListener('psy-drop', onPsyDrop); }, [songs, savePlaylist]); // ── Row mousedown: threshold drag for reorder (from anywhere on the row) ── const handleRowMouseDown = (e: React.MouseEvent, idx: number) => { if (e.button !== 0) return; if ((e.target as HTMLElement).closest('button, input')) return; e.preventDefault(); const sx = e.clientX, sy = e.clientY; const onMove = (me: MouseEvent) => { if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); if (!isFiltered && selectedIds.has(songs[idx]?.id) && selectedIds.size > 1) { const bulkTracks = songs.filter(s => selectedIds.has(s.id)).map(songToTrack); startDrag({ data: JSON.stringify({ type: 'songs', tracks: bulkTracks }), label: `${bulkTracks.length} Songs` }, me.clientX, me.clientY); } else if (!isFiltered) { startDrag( { data: JSON.stringify({ type: 'playlist_reorder', index: idx }), label: songs[idx]?.title ?? '' }, me.clientX, me.clientY ); } else { // filtered view: single-song drag to queue startDrag( { data: JSON.stringify({ type: 'song', track: songToTrack(songs[idx]) }), label: songs[idx]?.title ?? '' }, me.clientX, me.clientY ); } } }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }; // ── Memoized derivations ────────────────────────────────────── const existingIds = useMemo(() => new Set(songs.map(s => s.id)), [songs]); const tracks = useMemo(() => songs.map(songToTrack), [songs]); const displayedSongs = useMemo(() => { const q = filterText.trim().toLowerCase(); if (!q && sortKey === 'natural') return songs; let result = [...songs]; if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q)); if (sortKey !== 'natural') { result.sort((a, b) => { let av: string | number; let bv: string | number; const effectiveRating = (s: SubsonicSong) => ratings[s.id] ?? userRatingOverrides[s.id] ?? s.userRating ?? 0; const effectiveStarred = (s: SubsonicSong) => (s.id in starredOverrides ? starredOverrides[s.id] : starredSongs.has(s.id)) ? 1 : 0; switch (sortKey) { case 'title': av = a.title; bv = b.title; break; case 'artist': av = a.artist ?? ''; bv = b.artist ?? ''; break; case 'album': av = a.album ?? ''; bv = b.album ?? ''; break; case 'favorite': av = effectiveStarred(a); bv = effectiveStarred(b); break; case 'rating': av = effectiveRating(a); bv = effectiveRating(b); break; case 'duration': av = a.duration ?? 0; bv = b.duration ?? 0; break; default: av = a.title; bv = b.title; } if (typeof av === 'number' && typeof bv === 'number') { return sortDir === 'asc' ? av - bv : bv - av; } return sortDir === 'asc' ? (av as string).localeCompare(bv as string) : (bv as string).localeCompare(av as string); }); } return result; }, [songs, filterText, sortKey, sortDir, ratings, userRatingOverrides, starredOverrides, starredSongs]); const displayedTracks = useMemo( () => displayedSongs === songs ? tracks : displayedSongs.map(songToTrack), [displayedSongs, songs, tracks], ); const isFiltered = displayedSongs !== songs; // ── Drag-over visual feedback ───────────────────────────────── const handleRowMouseEnter = (idx: number, e: React.MouseEvent) => { if (!isDragging) return; const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const before = e.clientY < rect.top + rect.height / 2; setDropTargetIdx({ idx, before }); }; // ── Playback actions (encapsulated like AlbumHeader) ───────── const handlePlayAll = useCallback(() => { if (!songs.length || !id) return; touchPlaylist(id); playTrack(tracks[0], tracks); }, [songs.length, id, tracks, touchPlaylist, playTrack]); const handleShuffleAll = useCallback(() => { if (!songs.length || !id) return; touchPlaylist(id); const shuffled = [...tracks]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } playTrack(shuffled[0], shuffled); }, [songs.length, id, tracks, touchPlaylist, playTrack]); const handleEnqueueAll = useCallback(() => { if (!songs.length || !id) return; touchPlaylist(id); enqueue(tracks); }, [songs.length, id, tracks, touchPlaylist, enqueue]); // ── Render ──────────────────────────────────────────────────── if (loading) { return (
); } if (!playlist) { return
{t('playlists.notFound')}
; } return (
{/* ── Hero ── */}
{resolvedBgUrl && enableCoverArtBackground && ( <>