From fe6b1e9fade6ace4fc0de90696fce113e6e0386f Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Sun, 22 Feb 2026 21:31:53 +0000 Subject: [PATCH] favourites importing --- js/app.js | 68 ++++++-- js/playlist-importer.js | 335 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 386 insertions(+), 17 deletions(-) diff --git a/js/app.js b/js/app.js index 0374553..39bee34 100644 --- a/js/app.js +++ b/js/app.js @@ -48,7 +48,15 @@ import { trackOpenLyrics, trackCloseLyrics, } from './analytics.js'; -import { parseCSV, parseJSPF, parseXSPF, parseXML, parseM3U } from './playlist-importer.js'; +import { + parseCSV, + parseJSPF, + parseXSPF, + parseXML, + parseM3U, + parseDynamicCSV, + importToLibrary, +} from './playlist-importer.js'; // Lazy-loaded modules let settingsModule = null; @@ -1316,8 +1324,6 @@ document.addEventListener('DOMContentLoaded', async () => { }, 1000); } } else if (csvFileInput.files.length > 0) { - // Import from CSV - importSource = 'csv_import'; const file = csvFileInput.files[0]; const { progressElement, @@ -1337,20 +1343,60 @@ document.addEventListener('DOMContentLoaded', async () => { const csvText = await file.text(); const lines = csvText.trim().split('\n'); - const totalTracks = Math.max(0, lines.length - 1); - progressTotal.textContent = totalTracks.toString(); + const totalItems = Math.max(0, lines.length - 1); + progressTotal.textContent = totalItems.toString(); - const result = await parseCSV(csvText, api, (progress) => { - const percentage = totalTracks > 0 ? (progress.current / totalTracks) * 100 : 0; + const result = await parseDynamicCSV(csvText, api, (progress) => { + const percentage = totalItems > 0 ? (progress.current / totalItems) * 100 : 0; progressFill.style.width = `${Math.min(percentage, 100)}%`; progressCurrent.textContent = progress.current.toString(); - currentTrackElement.textContent = progress.currentTrack; - if (currentArtistElement) - currentArtistElement.textContent = progress.currentArtist || ''; + currentTrackElement.textContent = progress.currentItem; + if (currentArtistElement) { + currentArtistElement.textContent = progress.type + ? `Importing ${progress.type}...` + : ''; + } }); + const hasMultipleTypes = + result.tracks.length > 0 && (result.albums.length > 0 || result.artists.length > 0); + + if (hasMultipleTypes) { + currentTrackElement.textContent = 'Adding to library...'; + + const importResults = await importToLibrary(result, db, (progress) => { + if (progress.action === 'playlist') { + currentTrackElement.textContent = `Creating playlist: ${progress.item}`; + } else { + currentTrackElement.textContent = `Adding ${progress.action}: ${progress.item}`; + } + }); + + console.log('Import results:', importResults); + + const summary = []; + if (importResults.tracks.added > 0) + summary.push(`${importResults.tracks.added} tracks`); + if (importResults.albums.added > 0) + summary.push(`${importResults.albums.added} albums`); + if (importResults.artists.added > 0) + summary.push(`${importResults.artists.added} artists`); + if (importResults.playlists.created > 0) + summary.push(`${importResults.playlists.created} playlists`); + + alert( + `Imported to library:\n${summary.join(', ')}\n\n${ + result.missingItems.length > 0 + ? `${result.missingItems.length} items could not be found.` + : '' + }` + ); + progressElement.style.display = 'none'; + return; + } + tracks = result.tracks; - const missingTracks = result.missingTracks; + const missingTracks = result.missingItems.filter((i) => i.type === 'track'); if (tracks.length === 0) { alert('No valid tracks found in the CSV file! Please check the format.'); diff --git a/js/playlist-importer.js b/js/playlist-importer.js index 201f4c4..2e76b23 100644 --- a/js/playlist-importer.js +++ b/js/playlist-importer.js @@ -105,11 +105,336 @@ export function generateXML(playlist, 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: [] }; +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) { + return header + .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) { + 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 === 'track' || typeValue === 'favorite' || typeValue === 'favorite track') 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 (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] : ''; + + 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 = searchResult.items[0]; + } + } + + if (foundTrack) { + 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) { + 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) { + 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, + }, + }; +} + +export async function importToLibrary(csvResult, db, onProgress) { + const results = { + tracks: { added: 0, failed: 0 }, + albums: { added: 0, failed: 0 }, + artists: { added: 0, failed: 0 }, + playlists: { created: 0, tracksAdded: 0 }, + }; + + const addedTrackIds = new Set(); + const addedAlbumIds = new Set(); + const addedArtistIds = new Set(); + + for (const track of csvResult.tracks) { + if (!addedTrackIds.has(track.id)) { + try { + await db.toggleFavorite('track', track); + addedTrackIds.add(track.id); + results.tracks.added++; + } catch { + results.tracks.failed++; + } + } + if (onProgress) onProgress({ action: 'track', item: track.title }); + } + + for (const album of csvResult.albums) { + if (!addedAlbumIds.has(album.id)) { + try { + await db.toggleFavorite('album', album); + addedAlbumIds.add(album.id); + results.albums.added++; + } catch { + results.albums.failed++; + } + } + if (onProgress) onProgress({ action: 'album', item: album.title }); + } + + for (const artist of csvResult.artists) { + if (!addedArtistIds.has(artist.id)) { + try { + await db.toggleFavorite('artist', artist); + addedArtistIds.add(artist.id); + results.artists.added++; + } catch { + results.artists.failed++; + } + } + if (onProgress) onProgress({ action: 'artist', item: artist.name }); + } + + for (const [playlistName, playlistTracks] of Object.entries(csvResult.playlists)) { + if (playlistTracks.length > 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) { + 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 = ''; @@ -129,7 +454,6 @@ export async function parseCSV(csvText, api, onProgress) { } 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()); }; @@ -185,7 +509,6 @@ export async function parseCSV(csvText, api, onProgress) { }); } - // Search for the track if (trackTitle && artistNames) { await new Promise((resolve) => setTimeout(resolve, 300));