diff --git a/index.html b/index.html index 67405a3..48896ea 100644 --- a/index.html +++ b/index.html @@ -476,6 +476,51 @@ > JSPF + + +
@@ -517,6 +562,54 @@ />
+ + + + + +

Warning: This feature isn't perfect and is prone to errors! Please check your playlist after to remove any unwanted songs that were added by the system. diff --git a/js/analytics.js b/js/analytics.js index fce55e0..75c6a61 100644 --- a/js/analytics.js +++ b/js/analytics.js @@ -530,10 +530,27 @@ export function trackImportJSPF(playlistName, trackCount, missingCount, source) }); } -export function trackExportPlaylist(playlist) { - trackEvent('Export Playlist', { - playlist_name: playlist?.title || playlist?.name || 'Unknown', - track_count: playlist?.tracks?.length || 0, +export function trackImportXSPF(playlistName, trackCount, missingCount) { + trackEvent('Import XSPF', { + playlist_name: playlistName, + track_count: trackCount, + missing_count: missingCount, + }); +} + +export function trackImportXML(playlistName, trackCount, missingCount) { + trackEvent('Import XML', { + playlist_name: playlistName, + track_count: trackCount, + missing_count: missingCount, + }); +} + +export function trackImportM3U(playlistName, trackCount, missingCount) { + trackEvent('Import M3U', { + playlist_name: playlistName, + track_count: trackCount, + missing_count: missingCount, }); } diff --git a/js/app.js b/js/app.js index 1d37ba6..df2b525 100644 --- a/js/app.js +++ b/js/app.js @@ -29,6 +29,10 @@ import { trackCreatePlaylist, trackCreateFolder, trackImportJSPF, + trackImportCSV, + trackImportXSPF, + trackImportXML, + trackImportM3U, trackSelectLocalFolder, trackChangeLocalFolder, trackOpenModal, @@ -41,6 +45,7 @@ import { trackOpenLyrics, trackCloseLyrics, } from './analytics.js'; +import { parseCSV, parseJSPF, parseXSPF, parseXML, parseM3U } from './playlist-importer.js'; // Lazy-loaded modules let settingsModule = null; @@ -502,13 +507,21 @@ document.addEventListener('DOMContentLoaded', async () => { // Show/hide panels document.getElementById('csv-import-panel').style.display = importType === 'csv' ? 'block' : 'none'; document.getElementById('jspf-import-panel').style.display = importType === 'jspf' ? 'block' : 'none'; + document.getElementById('xspf-import-panel').style.display = importType === 'xspf' ? 'block' : 'none'; + document.getElementById('xml-import-panel').style.display = importType === 'xml' ? 'block' : 'none'; + document.getElementById('m3u-import-panel').style.display = importType === 'm3u' ? 'block' : 'none'; - // Clear the other file input - if (importType === 'csv') { - document.getElementById('jspf-file-input').value = ''; - } else { - document.getElementById('csv-file-input').value = ''; - } + // Clear all file inputs except the active one + document.getElementById('csv-file-input').value = + importType === 'csv' ? document.getElementById('csv-file-input').value : ''; + document.getElementById('jspf-file-input').value = + importType === 'jspf' ? document.getElementById('jspf-file-input').value : ''; + document.getElementById('xspf-file-input').value = + importType === 'xspf' ? document.getElementById('xspf-file-input').value : ''; + document.getElementById('xml-file-input').value = + importType === 'xml' ? document.getElementById('xml-file-input').value : ''; + document.getElementById('m3u-file-input').value = + importType === 'm3u' ? document.getElementById('m3u-file-input').value : ''; }); }); @@ -741,6 +754,9 @@ document.addEventListener('DOMContentLoaded', async () => { document.getElementById('import-section').style.display = 'block'; document.getElementById('csv-file-input').value = ''; document.getElementById('jspf-file-input').value = ''; + document.getElementById('xspf-file-input').value = ''; + document.getElementById('xml-file-input').value = ''; + document.getElementById('m3u-file-input').value = ''; // Reset import tabs to CSV document.querySelectorAll('.import-tab').forEach((tab) => { @@ -748,6 +764,9 @@ document.addEventListener('DOMContentLoaded', async () => { }); document.getElementById('csv-import-panel').style.display = 'block'; document.getElementById('jspf-import-panel').style.display = 'none'; + document.getElementById('xspf-import-panel').style.display = 'none'; + document.getElementById('xml-import-panel').style.display = 'none'; + document.getElementById('m3u-import-panel').style.display = 'none'; // Reset Public Toggle const publicToggle = document.getElementById('playlist-public-toggle'); @@ -848,23 +867,45 @@ document.addEventListener('DOMContentLoaded', async () => { // Create const csvFileInput = document.getElementById('csv-file-input'); const jspfFileInput = document.getElementById('jspf-file-input'); + const xspfFileInput = document.getElementById('xspf-file-input'); + const xmlFileInput = document.getElementById('xml-file-input'); + const m3uFileInput = document.getElementById('m3u-file-input'); let tracks = []; let importSource = 'manual'; let cover = document.getElementById('playlist-cover-input').value.trim(); - if (jspfFileInput.files.length > 0) { - // Import from JSPF - importSource = 'jspf_import'; - const file = jspfFileInput.files[0]; + // Helper function for import progress + const setupProgressElements = () => { const progressElement = document.getElementById('csv-import-progress'); const progressFill = document.getElementById('csv-progress-fill'); const progressCurrent = document.getElementById('csv-progress-current'); const progressTotal = document.getElementById('csv-progress-total'); const currentTrackElement = progressElement.querySelector('.current-track'); const currentArtistElement = progressElement.querySelector('.current-artist'); + return { + progressElement, + progressFill, + progressCurrent, + progressTotal, + currentTrackElement, + currentArtistElement, + }; + }; + + if (jspfFileInput.files.length > 0) { + // Import from JSPF + importSource = 'jspf_import'; + const file = jspfFileInput.files[0]; + const { + progressElement, + progressFill, + progressCurrent, + progressTotal, + currentTrackElement, + currentArtistElement, + } = setupProgressElements(); try { - // Show progress bar progressElement.style.display = 'block'; progressFill.style.width = '0%'; progressCurrent.textContent = '0'; @@ -921,7 +962,6 @@ document.addEventListener('DOMContentLoaded', async () => { jspfCreator ); - // if theres missing songs, warn the user if (missingTracks.length > 0) { setTimeout(() => { showMissingTracksNotification(missingTracks); @@ -933,7 +973,6 @@ document.addEventListener('DOMContentLoaded', async () => { progressElement.style.display = 'none'; return; } finally { - // Hide progress bar setTimeout(() => { progressElement.style.display = 'none'; }, 1000); @@ -942,15 +981,16 @@ document.addEventListener('DOMContentLoaded', async () => { // Import from CSV importSource = 'csv_import'; const file = csvFileInput.files[0]; - const progressElement = document.getElementById('csv-import-progress'); - const progressFill = document.getElementById('csv-progress-fill'); - const progressCurrent = document.getElementById('csv-progress-current'); - const progressTotal = document.getElementById('csv-progress-total'); - const currentTrackElement = progressElement.querySelector('.current-track'); - const currentArtistElement = progressElement.querySelector('.current-artist'); + const { + progressElement, + progressFill, + progressCurrent, + progressTotal, + currentTrackElement, + currentArtistElement, + } = setupProgressElements(); try { - // Show progress bar progressElement.style.display = 'block'; progressFill.style.width = '0%'; progressCurrent.textContent = '0'; @@ -981,7 +1021,8 @@ document.addEventListener('DOMContentLoaded', async () => { } console.log(`Imported ${tracks.length} tracks from CSV`); - // if theres missing songs, warn the user + trackImportCSV(name || 'Untitled', tracks.length, missingTracks.length); + if (missingTracks.length > 0) { setTimeout(() => { showMissingTracksNotification(missingTracks); @@ -993,7 +1034,183 @@ document.addEventListener('DOMContentLoaded', async () => { progressElement.style.display = 'none'; return; } finally { - // Hide progress bar + setTimeout(() => { + progressElement.style.display = 'none'; + }, 1000); + } + } else if (xspfFileInput.files.length > 0) { + // Import from XSPF + importSource = 'xspf_import'; + const file = xspfFileInput.files[0]; + const { + progressElement, + progressFill, + progressCurrent, + progressTotal, + currentTrackElement, + currentArtistElement, + } = setupProgressElements(); + + try { + progressElement.style.display = 'block'; + progressFill.style.width = '0%'; + progressCurrent.textContent = '0'; + currentTrackElement.textContent = 'Reading XSPF file...'; + if (currentArtistElement) currentArtistElement.textContent = ''; + + const xspfText = await file.text(); + + const result = await parseXSPF(xspfText, api, (progress) => { + const percentage = progress.total > 0 ? (progress.current / progress.total) * 100 : 0; + progressFill.style.width = `${Math.min(percentage, 100)}%`; + progressCurrent.textContent = progress.current.toString(); + progressTotal.textContent = progress.total.toString(); + currentTrackElement.textContent = progress.currentTrack; + if (currentArtistElement) + currentArtistElement.textContent = progress.currentArtist || ''; + }); + + tracks = result.tracks; + const missingTracks = result.missingTracks; + + if (tracks.length === 0) { + alert('No valid tracks found in the XSPF file! Please check the format.'); + progressElement.style.display = 'none'; + return; + } + console.log(`Imported ${tracks.length} tracks from XSPF`); + + trackImportXSPF(name || 'Untitled', tracks.length, missingTracks.length); + + if (missingTracks.length > 0) { + setTimeout(() => { + showMissingTracksNotification(missingTracks); + }, 500); + } + } catch (error) { + console.error('Failed to parse XSPF!', error); + alert('Failed to parse XSPF file! ' + error.message); + progressElement.style.display = 'none'; + return; + } finally { + setTimeout(() => { + progressElement.style.display = 'none'; + }, 1000); + } + } else if (xmlFileInput.files.length > 0) { + // Import from XML + importSource = 'xml_import'; + const file = xmlFileInput.files[0]; + const { + progressElement, + progressFill, + progressCurrent, + progressTotal, + currentTrackElement, + currentArtistElement, + } = setupProgressElements(); + + try { + progressElement.style.display = 'block'; + progressFill.style.width = '0%'; + progressCurrent.textContent = '0'; + currentTrackElement.textContent = 'Reading XML file...'; + if (currentArtistElement) currentArtistElement.textContent = ''; + + const xmlText = await file.text(); + + const result = await parseXML(xmlText, api, (progress) => { + const percentage = progress.total > 0 ? (progress.current / progress.total) * 100 : 0; + progressFill.style.width = `${Math.min(percentage, 100)}%`; + progressCurrent.textContent = progress.current.toString(); + progressTotal.textContent = progress.total.toString(); + currentTrackElement.textContent = progress.currentTrack; + if (currentArtistElement) + currentArtistElement.textContent = progress.currentArtist || ''; + }); + + tracks = result.tracks; + const missingTracks = result.missingTracks; + + if (tracks.length === 0) { + alert('No valid tracks found in the XML file! Please check the format.'); + progressElement.style.display = 'none'; + return; + } + console.log(`Imported ${tracks.length} tracks from XML`); + + trackImportXML(name || 'Untitled', tracks.length, missingTracks.length); + + if (missingTracks.length > 0) { + setTimeout(() => { + showMissingTracksNotification(missingTracks); + }, 500); + } + } catch (error) { + console.error('Failed to parse XML!', error); + alert('Failed to parse XML file! ' + error.message); + progressElement.style.display = 'none'; + return; + } finally { + setTimeout(() => { + progressElement.style.display = 'none'; + }, 1000); + } + } else if (m3uFileInput.files.length > 0) { + // Import from M3U/M3U8 + importSource = 'm3u_import'; + const file = m3uFileInput.files[0]; + const { + progressElement, + progressFill, + progressCurrent, + progressTotal, + currentTrackElement, + currentArtistElement, + } = setupProgressElements(); + + try { + progressElement.style.display = 'block'; + progressFill.style.width = '0%'; + progressCurrent.textContent = '0'; + currentTrackElement.textContent = 'Reading M3U file...'; + if (currentArtistElement) currentArtistElement.textContent = ''; + + const m3uText = await file.text(); + + const result = await parseM3U(m3uText, api, (progress) => { + const percentage = progress.total > 0 ? (progress.current / progress.total) * 100 : 0; + progressFill.style.width = `${Math.min(percentage, 100)}%`; + progressCurrent.textContent = progress.current.toString(); + progressTotal.textContent = progress.total.toString(); + currentTrackElement.textContent = progress.currentTrack; + if (currentArtistElement) + currentArtistElement.textContent = progress.currentArtist || ''; + }); + + tracks = result.tracks; + const missingTracks = result.missingTracks; + + if (tracks.length === 0) { + alert('No valid tracks found in the M3U file! Please check the format.'); + progressElement.style.display = 'none'; + return; + } + console.log(`Imported ${tracks.length} tracks from M3U`); + + trackImportM3U(name || 'Untitled', tracks.length, missingTracks.length); + + if (missingTracks.length > 0) { + setTimeout(() => { + showMissingTracksNotification(missingTracks); + }, 500); + } + } catch (error) { + console.error('Failed to parse M3U!', error); + alert('Failed to parse M3U file! ' + error.message); + progressElement.style.display = 'none'; + return; + } finally { setTimeout(() => { progressElement.style.display = 'none'; }, 1000); @@ -1789,453 +2006,6 @@ function showMissingTracksNotification(missingTracks) { modal.classList.add('active'); } -async function parseCSV(csvText, api, onProgress) { - const lines = csvText.trim().split('\n'); - if (lines.length < 2) return []; - - // Robust CSV line parser that respects quotes - 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); - - // Clean up quotes: remove surrounding quotes and unescape double quotes if any - 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; // Skip empty lines - - 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': - trackTitle = value; - break; - case 'artist name(s)': - case 'artist name': - case 'artist': - case 'artists': - artistNames = value; - break; - case 'album': - case 'album name': - albumName = value; - break; - } - }); - - if (onProgress) { - onProgress({ - current: i, - total: totalTracks, - currentTrack: trackTitle || 'Unknown track', - currentArtist: artistNames || '', - }); - } - - // Search for the track in hifi tidal api's catalog - if (trackTitle && artistNames) { - // Add a small delay to prevent rate limiting - await new Promise((resolve) => setTimeout(resolve, 300)); - - try { - let foundTrack = null; - - // Helper: Normalize strings for fuzzy matching - const normalize = (str) => - str - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() - .replace(/[^\w\s]/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - - // Helper: Check if result matches our criteria - const isValidMatch = (track, title, artists, album) => { - if (!track) return false; - - const trackTitle = normalize(track.title || ''); - const trackArtists = (track.artists || []).map((a) => normalize(a.name || '')).join(' '); - const trackAlbum = normalize(track.album?.name || ''); - - const queryTitle = normalize(title); - const queryArtists = normalize(artists); - const queryAlbum = normalize(album || ''); - - // Must match title (exact or substring match) - const titleMatch = - trackTitle === queryTitle || - trackTitle.includes(queryTitle) || - queryTitle.includes(trackTitle); - if (!titleMatch) return false; - - // Must match at least one artist - const artistMatch = - trackArtists.includes(queryArtists.split(' ')[0]) || - queryArtists.includes(trackArtists.split(' ')[0]); - if (!artistMatch) return false; - - // If album provided, prefer matching album but not strict - if (queryAlbum) { - const albumMatch = - trackAlbum === queryAlbum || - trackAlbum.includes(queryAlbum) || - queryAlbum.includes(trackAlbum); - return albumMatch; - } - - return true; - }; - - // 1. Initial Search: Title + All Artists + Album (most specific) - if (!foundTrack) { - let searchQuery = `${trackTitle} ${artistNames}`; - if (albumName) searchQuery += ` ${albumName}`; - const searchResults = await api.searchTracks(searchQuery); - - if (searchResults.items && searchResults.items.length > 0) { - // Try to find best match within results - for (const result of searchResults.items) { - if (isValidMatch(result, trackTitle, artistNames, albumName)) { - foundTrack = result; - break; - } - } - // Fallback: if no valid match found, use first result only if album matches - if (!foundTrack && albumName) { - const firstResult = searchResults.items[0]; - if (isValidMatch(firstResult, trackTitle, artistNames, albumName)) { - foundTrack = firstResult; - } - } - } - } - - // 2. Retry: Title + Main Artist + Album - if (!foundTrack && artistNames) { - const mainArtist = artistNames.split(',')[0].trim(); - if (mainArtist && mainArtist !== artistNames) { - let searchQuery = `${trackTitle} ${mainArtist}`; - if (albumName) searchQuery += ` ${albumName}`; - const searchResults = await api.searchTracks(searchQuery); - - if (searchResults.items && searchResults.items.length > 0) { - for (const result of searchResults.items) { - if (isValidMatch(result, trackTitle, mainArtist, albumName)) { - foundTrack = result; - console.log(`Found (Retry 1 - Main Artist): ${trackTitle}`); - break; - } - } - } - } - } - - // 3. Retry: Just Title + Album (strong album context) - if (!foundTrack && albumName) { - const searchQuery = `${trackTitle} ${albumName}`; - const searchResults = await api.searchTracks(searchQuery); - - if (searchResults.items && searchResults.items.length > 0) { - for (const result of searchResults.items) { - if (isValidMatch(result, trackTitle, artistNames, albumName)) { - foundTrack = result; - console.log(`Found (Retry 2 - Album): ${trackTitle}`); - break; - } - } - } - } - - // Clean title for retry strategies - // Remove " - ", "(feat. ...)", "[feat. ...]" - const cleanTitle = (t) => - t - .split(' - ')[0] - .replace(/\s*[([]feat\.?.*?[)\]]/i, '') - .trim(); - const cleanedTitle = cleanTitle(trackTitle); - const isTitleCleaned = cleanedTitle !== trackTitle; - - // 4. Retry: Cleaned Title + Main Artist + Album - if (!foundTrack && isTitleCleaned) { - const mainArtist = (artistNames || '').split(',')[0].trim(); - if (cleanedTitle) { - let searchQuery = `${cleanedTitle} ${mainArtist}`; - if (albumName) searchQuery += ` ${albumName}`; - const searchResults = await api.searchTracks(searchQuery); - - if (searchResults.items && searchResults.items.length > 0) { - for (const result of searchResults.items) { - if (isValidMatch(result, cleanedTitle, mainArtist, albumName)) { - foundTrack = result; - console.log(`Found (Retry 3 - Cleaned Title): ${trackTitle}`); - break; - } - } - } - } - } - - // 5. Retry: Title + Main Artist (Ignore Album in Query and Match) - if (!foundTrack) { - const mainArtist = (artistNames || '').split(',')[0].trim(); - // Search WITHOUT album name to find tracks where album metadata differs - const searchQuery = `${trackTitle} ${mainArtist}`; - const searchResults = await api.searchTracks(searchQuery); - - if (searchResults.items && searchResults.items.length > 0) { - for (const result of searchResults.items) { - // Pass null for album to ignore it in validation - if (isValidMatch(result, trackTitle, mainArtist, null)) { - foundTrack = result; - console.log(`Found (Retry 4 - Ignore Album): ${trackTitle}`); - break; - } - } - } - } - - // 6. Retry: Cleaned Title + Main Artist (Ignore Album in Query and Match) - if (!foundTrack && isTitleCleaned) { - const mainArtist = (artistNames || '').split(',')[0].trim(); - const searchQuery = `${cleanedTitle} ${mainArtist}`; - const searchResults = await api.searchTracks(searchQuery); - - if (searchResults.items && searchResults.items.length > 0) { - for (const result of searchResults.items) { - if (isValidMatch(result, cleanedTitle, mainArtist, null)) { - foundTrack = result; - console.log(`Found (Retry 5 - Cleaned Title + Ignore Album): ${trackTitle}`); - break; - } - } - } - } - - if (foundTrack) { - tracks.push(foundTrack); - console.log(`✓ "${trackTitle}" by ${artistNames}${albumName ? ' [' + albumName + ']' : ''}`); - } else { - console.warn( - `✗ Track not found: "${trackTitle}" by ${artistNames}${albumName ? ' [' + albumName + ']' : ''}` - ); - missingTracks.push( - `${trackTitle} - ${artistNames}${albumName ? ' (album: ' + albumName + ')' : ''}` - ); - } - } catch (error) { - console.error(`Error searching for track "${trackTitle}":`, error); - missingTracks.push( - `${trackTitle} - ${artistNames}${albumName ? ' (album: ' + albumName + ')' : ''}` - ); - } - } - } - } - - // yayyy its finished :P - if (onProgress) { - onProgress({ - current: totalTracks, - total: totalTracks, - currentTrack: 'Import complete', - }); - } - - return { tracks, missingTracks }; -} - -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; - - // Helper: Normalize strings for fuzzy matching - const normalize = (str) => - str - ?.normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() - .replace(/[^\w\s]/g, ' ') - .replace(/\s+/g, ' ') - .trim() || ''; - - // Helper: Check if result matches our criteria - const isValidMatch = (track, title, artists, album) => { - if (!track) return false; - - const trackTitle = normalize(track.title || ''); - const trackArtists = (track.artists || []).map((a) => normalize(a.name || '')).join(' '); - const trackAlbum = normalize(track.album?.name || ''); - - const queryTitle = normalize(title); - const queryArtists = normalize(artists); - const queryAlbum = normalize(album || ''); - - // Must match title (exact or substring match) - const titleMatch = - trackTitle === queryTitle || trackTitle.includes(queryTitle) || queryTitle.includes(trackTitle); - if (!titleMatch) return false; - - // Must match at least one artist - const artistMatch = - trackArtists.includes(queryArtists.split(' ')[0]) || queryArtists.includes(trackArtists.split(' ')[0]); - if (!artistMatch) return false; - - // If album provided, prefer matching album but not strict - if (queryAlbum) { - const albumMatch = - trackAlbum === queryAlbum || trackAlbum.includes(queryAlbum) || queryAlbum.includes(trackAlbum); - return albumMatch; - } - - return true; - }; - - 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 || '', - }); - } - - // Try to find track - let foundTrack = null; - - if (trackTitle && trackCreator) { - // Add delay to prevent rate limiting - await new Promise((resolve) => setTimeout(resolve, 300)); - - try { - // 1. Search with title + artist + album - let searchQuery = `${trackTitle} ${trackCreator}`; - if (trackAlbum) searchQuery += ` ${trackAlbum}`; - const searchResults = await api.searchTracks(searchQuery); - - if (searchResults.items && searchResults.items.length > 0) { - for (const result of searchResults.items) { - if (isValidMatch(result, trackTitle, trackCreator, trackAlbum)) { - foundTrack = result; - break; - } - } - } - - // 2. Retry with main artist only - if (!foundTrack) { - const mainArtist = trackCreator.split(',')[0].trim(); - if (mainArtist && mainArtist !== trackCreator) { - const searchResults = await api.searchTracks(`${trackTitle} ${mainArtist}`); - if (searchResults.items) { - for (const result of searchResults.items) { - if (isValidMatch(result, trackTitle, mainArtist, trackAlbum)) { - foundTrack = result; - break; - } - } - } - } - } - - // 3. Try just title + artist, ignoring album - if (!foundTrack) { - const searchResults = await api.searchTracks(`${trackTitle} ${trackCreator}`); - if (searchResults.items) { - for (const result of searchResults.items) { - if (isValidMatch(result, trackTitle, trackCreator, null)) { - foundTrack = result; - break; - } - } - } - } - - if (foundTrack) { - tracks.push(foundTrack); - console.log(`✓ "${trackTitle}" by ${trackCreator}`); - } else { - console.warn(`✗ Track not found: "${trackTitle}" by ${trackCreator}`); - missingTracks.push( - `${trackTitle} - ${trackCreator}${trackAlbum ? ' (' + trackAlbum + ')' : ''}` - ); - } - } catch (error) { - console.error(`Error searching for track "${trackTitle}":`, error); - missingTracks.push(`${trackTitle} - ${trackCreator}${trackAlbum ? ' (' + trackAlbum + ')' : ''}`); - } - } else { - missingTracks.push(`Invalid track entry at position ${i + 1}`); - } - } - - // Final progress update - if (onProgress) { - onProgress({ - current: totalTracks, - total: totalTracks, - currentTrack: 'Import complete', - }); - } - - return { tracks, missingTracks, jspfData }; - } catch (error) { - console.error('JSPF parsing error:', error); - throw new Error('Failed to parse JSPF file: ' + error.message); - } -} - function showDiscographyDownloadModal(artist, api, quality, lyricsManager, triggerBtn) { const modal = document.getElementById('discography-download-modal'); diff --git a/js/playlist-importer.js b/js/playlist-importer.js new file mode 100644 index 0000000..ca39d17 --- /dev/null +++ b/js/playlist-importer.js @@ -0,0 +1,494 @@ +import { sanitizeForFilename } from './utils.js'; + +/** + * 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}>} + */ +export async function parseCSV(csvText, api, onProgress) { + const lines = csvText.trim().split('\n'); + if (lines.length < 2) return { tracks: [], missingTracks: [] }; + + // Robust CSV line parser that respects quotes + 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); + + // Clean up quotes: remove surrounding quotes and unescape double quotes if any + 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 || '', + }); + } + + // Search for the track + 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) { + tracks.push(searchResult.items[0]); + } else { + missingTracks.push({ title: trackTitle, artist: artistNames, album: albumName }); + } + } catch (e) { + 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 (e) { + 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) { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(xspfText, 'text/xml'); + + const trackList = xmlDoc.getElementsByTagName('track'); + const tracks = []; + const missingTracks = []; + const totalTracks = trackList.length; + + for (let i = 0; i < trackList.length; i++) { + const trackEl = trackList[i]; + const title = trackEl.getElementsByTagName('title')[0]?.textContent || ''; + const creator = trackEl.getElementsByTagName('creator')[0]?.textContent || ''; + const album = trackEl.getElementsByTagName('album')[0]?.textContent || ''; + + if (onProgress) { + onProgress({ + current: i, + total: totalTracks, + currentTrack: title || 'Unknown track', + currentArtist: creator || '', + }); + } + + if (title && creator) { + await new Promise((resolve) => 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 (e) { + 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) { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(xmlText, 'text/xml'); + + // Try different track element names + let trackElements = xmlDoc.getElementsByTagName('track'); + if (trackElements.length === 0) { + trackElements = xmlDoc.getElementsByTagName('song'); + } + if (trackElements.length === 0) { + trackElements = xmlDoc.getElementsByTagName('item'); + } + + const tracks = []; + const missingTracks = []; + const totalTracks = trackElements.length; + + for (let i = 0; i < trackElements.length; i++) { + const trackEl = trackElements[i]; + + // Try different element names for title/artist + const title = + trackEl.getElementsByTagName('title')[0]?.textContent || + trackEl.getElementsByTagName('name')[0]?.textContent || + ''; + const artist = + trackEl.getElementsByTagName('artist')[0]?.textContent || + trackEl.getElementsByTagName('creator')[0]?.textContent || + trackEl.getElementsByTagName('performer')[0]?.textContent || + ''; + const album = trackEl.getElementsByTagName('album')[0]?.textContent || ''; + + if (onProgress) { + onProgress({ + current: i, + total: totalTracks, + currentTrack: title || 'Unknown track', + currentArtist: artist || '', + }); + } + + if (title && artist) { + await new Promise((resolve) => 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 (e) { + 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 (e) { + 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 };