function isFuzzyMatch(str1, str2) { if (!str1 || !str2) return false; const s1 = str1.toLowerCase().replace(/[^\p{L}\p{N}]/gu, ''); const s2 = str2.toLowerCase().replace(/[^\p{L}\p{N}]/gu, ''); return s1.includes(s2) || s2.includes(s1); } function findBestMatch(items, targetArtist, targetAlbum, importOptions) { if (!items || items.length === 0) return null; if (!importOptions?.strictArtistMatch && !importOptions?.strictAlbumMatch) return items[0]; return ( items.find((item) => { let artistOk = true; let albumOk = true; if (importOptions.strictArtistMatch && targetArtist) { const itemArtist = item.artist?.name || item.artists?.[0]?.name; if (!isFuzzyMatch(itemArtist, targetArtist)) artistOk = false; } if (importOptions.strictAlbumMatch && targetAlbum) { const itemAlbum = item.album?.title; if (itemAlbum && !isFuzzyMatch(itemAlbum, targetAlbum)) albumOk = false; } return artistOk && albumOk; }) || null ); } /** * Helper function to get track artists string */ function getTrackArtists(track) { if (track.artists && track.artists.length > 0) { return track.artists.map((artist) => artist.name).join(', '); } return track.artist?.name || 'Unknown Artist'; } /** * Generates CSV playlist export * @param {Object} playlist - Playlist metadata * @param {Array} tracks - Array of track objects * @returns {string} CSV content */ export function generateCSV(playlist, tracks) { const headers = ['Track Name', 'Artist Name(s)', 'Album', 'Duration']; let content = headers.map((h) => `"${h}"`).join(',') + '\n'; tracks.forEach((track) => { const title = (track.title || '').replace(/"/g, '""'); const artist = getTrackArtists(track).replace(/"/g, '""'); const album = (track.album?.title || '').replace(/"/g, '""'); const duration = formatDuration(track.duration || 0); content += `"${title}","${artist}","${album}","${duration}"\n`; }); return content; } /** * Generates XSPF (XML Shareable Playlist Format) export * @param {Object} playlist - Playlist metadata * @param {Array} tracks - Array of track objects * @returns {string} XSPF XML content */ export function generateXSPF(playlist, tracks) { const date = new Date().toISOString(); let xml = '\n'; xml += '\n'; xml += ` ${escapeXml(playlist.title || 'Unknown Playlist')}\n`; xml += ` ${escapeXml(playlist.artist || 'Various Artists')}\n`; xml += ` ${date}\n`; xml += ' \n'; tracks.forEach((track) => { xml += ' \n'; xml += ` ${escapeXml(track.title || 'Unknown Title')}\n`; xml += ` ${escapeXml(getTrackArtists(track))}\n`; if (track.album?.title) { xml += ` ${escapeXml(track.album.title)}\n`; } if (track.duration) { xml += ` ${Math.round(track.duration * 1000)}\n`; } xml += ' \n'; }); xml += ' \n'; xml += '\n'; return xml; } /** * Generates generic XML playlist export * @param {Object} playlist - Playlist metadata * @param {Array} tracks - Array of track objects * @returns {string} XML content */ export function generateXML(playlist, tracks) { const date = new Date().toISOString(); let xml = '\n'; xml += '\n'; xml += ` ${escapeXml(playlist.title || 'Unknown Playlist')}\n`; xml += ` ${escapeXml(playlist.artist || 'Various Artists')}\n`; xml += ` ${date}\n`; xml += ` ${tracks.length}\n`; xml += ' \n'; tracks.forEach((track, index) => { xml += ' \n'; xml += ` ${index + 1}\n`; xml += ` ${escapeXml(track.title || '')}\n`; xml += ` ${escapeXml(getTrackArtists(track) || '')}\n`; xml += ` ${escapeXml(track.album?.title || '')}\n`; xml += ` ${Math.round(track.duration || 0)}\n`; xml += ' \n'; }); xml += ' \n'; xml += '\n'; return xml; } /** * Parses CSV playlist format * @param {string} csvText - CSV content * @param {Function} api - API instance for searching tracks * @param {Function} onProgress - Progress callback * @returns {Promise<{tracks: Array, missingTracks: Array}>} */ const HEADER_MAPPINGS = { track: ['track name', 'title', 'song', 'name', 'track', 'track title'], artist: ['artist name(s)', 'artist name', 'artist', 'artists', 'creator', 'artist names'], album: ['album', 'album name'], type: ['type', 'category', 'kind'], isrc: ['isrc', 'isrc code'], spotifyId: ['spotify - id', 'spotify id', 'spotify_id', 'spotifyid'], playlistName: ['playlist name', 'playlist', 'playlist title'], duration: ['duration', 'length', 'time'], }; function normalizeHeader(header) { if (!header) return ''; return header .replace(/^\uFEFF/, '') .toLowerCase() .trim() .replace(/[_\s]+/g, ' '); } function mapHeaders(rawHeaders) { const mapped = {}; rawHeaders.forEach((header, index) => { const normalized = normalizeHeader(header); for (const [key, aliases] of Object.entries(HEADER_MAPPINGS)) { if (aliases.includes(normalized)) { mapped[key] = index; break; } } }); return mapped; } function detectCSVFormat(mappedHeaders) { const hasType = mappedHeaders.type !== undefined; const hasTrack = mappedHeaders.track !== undefined; const hasArtist = mappedHeaders.artist !== undefined; const hasAlbum = mappedHeaders.album !== undefined; if (hasTrack && hasArtist) { return { format: 'library', hasMultipleTypes: hasType, supportsTracks: true, supportsAlbums: hasAlbum, supportsArtists: hasArtist && !hasTrack, }; } if (hasArtist && !hasTrack) { return { format: 'artists', hasMultipleTypes: false, supportsTracks: false, supportsAlbums: false, supportsArtists: true, }; } return { format: 'playlist', hasMultipleTypes: false, supportsTracks: true, supportsAlbums: false, supportsArtists: false, }; } export async function parseDynamicCSV(csvText, api, onProgress, options = {}) { const lines = csvText.trim().split('\n'); if (lines.length < 2) { return { format: 'unknown', tracks: [], albums: [], artists: [], missingItems: [], playlists: {}, }; } const parseLine = (text) => { const values = []; let current = ''; let inQuote = false; for (let i = 0; i < text.length; i++) { const char = text[i]; if (char === '"') { inQuote = !inQuote; } else if (char === ',' && !inQuote) { values.push(current); current = ''; } else { current += char; } } values.push(current); return values.map((v) => v.trim().replace(/^"|"$/g, '').replace(/""/g, '"').trim()); }; const rawHeaders = parseLine(lines[0]); const mappedHeaders = mapHeaders(rawHeaders); const formatInfo = detectCSVFormat(mappedHeaders); const rows = lines.slice(1); const tracks = []; const albums = []; const artists = []; const missingItems = []; const playlists = {}; const totalItems = rows.length; const getItemType = (values) => { if (mappedHeaders.type !== undefined) { const typeValue = values[mappedHeaders.type]?.toLowerCase().trim(); if (typeValue === 'album' || typeValue === 'favorite album') return 'album'; if (typeValue === 'artist' || typeValue === 'favorite artist') return 'artist'; if ( typeValue === 'favorite' || typeValue === 'favorite track' || typeValue === 'track' || typeValue === 'playlist' ) return 'track'; } const hasTrackName = mappedHeaders.track !== undefined && values[mappedHeaders.track]; const hasArtistName = mappedHeaders.artist !== undefined && values[mappedHeaders.artist]; const hasAlbumName = mappedHeaders.album !== undefined && values[mappedHeaders.album]; if (hasTrackName && hasArtistName) return 'track'; if (hasTrackName && hasAlbumName && values[mappedHeaders.track] === values[mappedHeaders.album]) { return hasArtistName ? 'track' : 'album'; } if (hasAlbumName && hasArtistName && !hasTrackName) return 'album'; if (hasArtistName && !hasTrackName && !hasAlbumName) return 'artist'; return 'track'; }; for (let i = 0; i < rows.length; i++) { const row = rows[i]; if (!row.trim()) continue; const values = parseLine(row); const itemType = getItemType(values); const trackName = mappedHeaders.track !== undefined ? values[mappedHeaders.track] : ''; const artistName = mappedHeaders.artist !== undefined ? values[mappedHeaders.artist] : ''; const albumName = mappedHeaders.album !== undefined ? values[mappedHeaders.album] : ''; const isrc = mappedHeaders.isrc !== undefined ? values[mappedHeaders.isrc] : ''; const playlistName = mappedHeaders.playlistName !== undefined ? values[mappedHeaders.playlistName] : ''; const typeValue = mappedHeaders.type !== undefined ? values[mappedHeaders.type]?.toLowerCase().trim() : ''; const isFavorite = typeValue.includes('favorite'); if (onProgress) { onProgress({ current: i, total: totalItems, currentItem: trackName || artistName || albumName || 'Unknown item', type: itemType, }); } await new Promise((resolve) => setTimeout(resolve, 300)); try { if (itemType === 'track') { let foundTrack = null; if (isrc) { const searchResult = await api.searchTracks(`isrc:${isrc}`); if (searchResult.items && searchResult.items.length > 0) { foundTrack = searchResult.items.find((t) => t.isrc === isrc) || searchResult.items[0]; } } if (!foundTrack && trackName && artistName) { const searchQuery = `"${trackName}" ${artistName}`.trim(); const searchResult = await api.searchTracks(searchQuery); if (searchResult.items && searchResult.items.length > 0) { foundTrack = findBestMatch(searchResult.items, artistName, albumName, options); } } if (foundTrack) { if (isFavorite) foundTrack.isFavorite = true; tracks.push(foundTrack); if (playlistName) { if (!playlists[playlistName]) { playlists[playlistName] = []; } playlists[playlistName].push(foundTrack); } } else { missingItems.push({ type: 'track', title: trackName, artist: artistName, album: albumName, isrc: isrc, }); } } else if (itemType === 'album') { let foundAlbum = null; if (artistName && albumName) { const searchQuery = `"${albumName}" ${artistName}`.trim(); const searchResult = await api.searchAlbums(searchQuery); if (searchResult.items && searchResult.items.length > 0) { foundAlbum = searchResult.items[0]; } } if (foundAlbum) { if (isFavorite) foundAlbum.isFavorite = true; albums.push(foundAlbum); } else { missingItems.push({ type: 'album', title: albumName, artist: artistName, }); } } else if (itemType === 'artist') { let foundArtist = null; if (artistName) { const searchResult = await api.searchArtists(artistName); if (searchResult.items && searchResult.items.length > 0) { foundArtist = searchResult.items[0]; } } if (foundArtist) { if (isFavorite) foundArtist.isFavorite = true; artists.push(foundArtist); } else { missingItems.push({ type: 'artist', name: artistName, }); } } } catch { missingItems.push({ type: itemType, title: trackName || albumName, artist: artistName, }); } } return { format: formatInfo.format, tracks, albums, artists, missingItems, playlists, stats: { totalItems, tracksFound: tracks.length, albumsFound: albums.length, artistsFound: artists.length, missingCount: missingItems.length, playlistCount: Object.keys(playlists).length, }, }; } /** * Imports CSV result to library * @param {Object} csvResult - Result from parseDynamicCSV * @param {Object} db - Database instance * @param {Function} onProgress - Progress callback * @param {Object} options - Import options * @returns {Promise 0) { try { await db.createPlaylist(playlistName, playlistTracks); results.playlists.created++; results.playlists.tracksAdded += playlistTracks.length; } catch { console.warn(`Failed to create playlist: ${playlistName}`); } } if (onProgress) onProgress({ action: 'playlist', item: playlistName }); } return results; } export async function parseCSV(csvText, api, onProgress, importOptions = {}) { const lines = csvText.trim().split('\n'); if (lines.length < 2) return { tracks: [], missingTracks: [] }; const parseLine = (text) => { const values = []; let current = ''; let inQuote = false; for (let i = 0; i < text.length; i++) { const char = text[i]; if (char === '"') { inQuote = !inQuote; } else if (char === ',' && !inQuote) { values.push(current); current = ''; } else { current += char; } } values.push(current); return values.map((v) => v.trim().replace(/^"|"$/g, '').replace(/""/g, '"').trim()); }; const headers = parseLine(lines[0]); const rows = lines.slice(1); const tracks = []; const missingTracks = []; const totalTracks = rows.length; for (let i = 0; i < rows.length; i++) { const row = rows[i]; if (!row.trim()) continue; const values = parseLine(row); if (values.length >= headers.length) { let trackTitle = ''; let artistNames = ''; let albumName = ''; headers.forEach((header, index) => { const value = values[index]; if (!value) return; switch (header.toLowerCase()) { case 'track name': case 'title': case 'song': case 'name': trackTitle = value; break; case 'artist name(s)': case 'artist name': case 'artist': case 'artists': case 'creator': artistNames = value; break; case 'album': case 'album name': albumName = value; break; } }); if (onProgress) { onProgress({ current: i, total: totalTracks, currentTrack: trackTitle || 'Unknown track', currentArtist: artistNames || '', }); } if (trackTitle && artistNames) { await new Promise((resolve) => setTimeout(resolve, 300)); try { const searchQuery = `"${trackTitle}" ${artistNames}`.trim(); const searchResult = await api.searchTracks(searchQuery); if (searchResult.items && searchResult.items.length > 0) { const match = findBestMatch(searchResult.items, artistNames, albumName, importOptions); if (match) tracks.push(match); else missingTracks.push({ title: trackTitle, artist: artistNames, album: albumName }); } else { missingTracks.push({ title: trackTitle, artist: artistNames, album: albumName }); } } catch { missingTracks.push({ title: trackTitle, artist: artistNames, album: albumName }); } } } } return { tracks, missingTracks }; } /** * Parses JSPF (JSON Shareable Playlist Format) * @param {string} jspfText - JSPF JSON content * @param {Function} api - API instance for searching tracks * @param {Function} onProgress - Progress callback * @returns {Promise<{tracks: Array, missingTracks: Array}>} */ export async function parseJSPF(jspfText, api, onProgress) { try { const jspfData = JSON.parse(jspfText); if (!jspfData.playlist || !Array.isArray(jspfData.playlist.track)) { throw new Error('Invalid JSPF format: missing playlist or track array'); } const playlist = jspfData.playlist; const tracks = []; const missingTracks = []; const totalTracks = playlist.track.length; for (let i = 0; i < playlist.track.length; i++) { const jspfTrack = playlist.track[i]; const trackTitle = jspfTrack.title; const trackCreator = jspfTrack.creator; const trackAlbum = jspfTrack.album; if (onProgress) { onProgress({ current: i, total: totalTracks, currentTrack: trackTitle || 'Unknown track', currentArtist: trackCreator || '', }); } if (trackTitle && trackCreator) { await new Promise((resolve) => setTimeout(resolve, 300)); try { const searchQuery = `${trackTitle} ${trackCreator}`; const searchResults = await api.searchTracks(searchQuery); if (searchResults.items && searchResults.items.length > 0) { tracks.push(searchResults.items[0]); } else { missingTracks.push({ title: trackTitle, artist: trackCreator, album: trackAlbum }); } } catch { missingTracks.push({ title: trackTitle, artist: trackCreator, album: trackAlbum }); } } } return { tracks, missingTracks }; } catch (error) { throw new Error('Failed to parse JSPF: ' + error.message); } } /** * Parses XSPF (XML Shareable Playlist Format) * @param {string} xspfText - XSPF XML content * @param {Function} api - API instance for searching tracks * @param {Function} onProgress - Progress callback * @returns {Promise<{tracks: Array, missingTracks: Array}>} */ export async function parseXSPF(xspfText, api, onProgress) { // Validate input to prevent potential XXE attacks if (!xspfText || typeof xspfText !== 'string' || xspfText.length > 10 * 1024 * 1024) { throw new Error('Invalid XSPF content'); } // Reject potential XXE payloads if (xspfText.includes(' setTimeout(resolve, 300)); try { const searchQuery = `${title} ${creator}`; const searchResults = await api.searchTracks(searchQuery); if (searchResults.items && searchResults.items.length > 0) { tracks.push(searchResults.items[0]); } else { missingTracks.push({ title, artist: creator, album }); } } catch { missingTracks.push({ title, artist: creator, album }); } } } return { tracks, missingTracks }; } /** * Parses generic XML playlist format * @param {string} xmlText - XML content * @param {Function} api - API instance for searching tracks * @param {Function} onProgress - Progress callback * @returns {Promise<{tracks: Array, missingTracks: Array}>} */ export async function parseXML(xmlText, api, onProgress) { // Validate input to prevent potential XXE attacks if (!xmlText || typeof xmlText !== 'string' || xmlText.length > 10 * 1024 * 1024) { throw new Error('Invalid XML content'); } // Reject potential XXE payloads if (xmlText.includes(' setTimeout(resolve, 300)); try { const searchQuery = `${title} ${artist}`; const searchResults = await api.searchTracks(searchQuery); if (searchResults.items && searchResults.items.length > 0) { tracks.push(searchResults.items[0]); } else { missingTracks.push({ title, artist, album }); } } catch { missingTracks.push({ title, artist, album }); } } } return { tracks, missingTracks }; } /** * Parses M3U/M3U8 playlist format * @param {string} m3uText - M3U content * @param {Function} api - API instance for searching tracks * @param {Function} onProgress - Progress callback * @returns {Promise<{tracks: Array, missingTracks: Array}>} */ export async function parseM3U(m3uText, api, onProgress) { const lines = m3uText.trim().split('\n'); const tracks = []; const missingTracks = []; const trackInfo = []; let currentInfo = null; // Parse M3U format for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#EXTM3U')) continue; if (trimmed.startsWith('#EXTINF:')) { // Parse EXTINF line: #EXTINF:duration,Artist - Title const match = trimmed.match(/#EXTINF:(-?\d+)?,(.+)/); if (match) { const displayName = match[2]; const parts = displayName.split(' - '); currentInfo = { title: parts.length > 1 ? parts.slice(1).join(' - ') : displayName, artist: parts.length > 1 ? parts[0] : '', }; } } else if (!trimmed.startsWith('#')) { // This is a file path line if (currentInfo) { trackInfo.push(currentInfo); currentInfo = null; } } } const totalTracks = trackInfo.length; for (let i = 0; i < trackInfo.length; i++) { const info = trackInfo[i]; if (onProgress) { onProgress({ current: i, total: totalTracks, currentTrack: info.title || 'Unknown track', currentArtist: info.artist || '', }); } if (info.title) { await new Promise((resolve) => setTimeout(resolve, 300)); try { const searchQuery = info.artist ? `${info.title} ${info.artist}` : info.title; const searchResults = await api.searchTracks(searchQuery); if (searchResults.items && searchResults.items.length > 0) { tracks.push(searchResults.items[0]); } else { missingTracks.push({ title: info.title, artist: info.artist, album: '' }); } } catch { missingTracks.push({ title: info.title, artist: info.artist, album: '' }); } } } return { tracks, missingTracks }; } /** * Formats duration in MM:SS format * @param {number} seconds - Duration in seconds * @returns {string} Formatted duration */ function formatDuration(seconds) { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; } /** * Helper function to escape XML special characters */ function escapeXml(text) { if (!text) return ''; return text .toString() .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // Export all functions export { getTrackArtists };