more imports

This commit is contained in:
IsraelGPT 2026-02-16 00:36:36 +00:00
parent a034374859
commit 830155c14a
4 changed files with 847 additions and 473 deletions

View file

@ -476,6 +476,51 @@
> >
JSPF JSPF
</button> </button>
<button
type="button"
class="import-tab"
data-import-type="xspf"
style="
background: transparent;
border: none;
color: var(--foreground);
cursor: pointer;
padding: 0.25rem 0.5rem;
opacity: 0.7;
"
>
XSPF
</button>
<button
type="button"
class="import-tab"
data-import-type="xml"
style="
background: transparent;
border: none;
color: var(--foreground);
cursor: pointer;
padding: 0.25rem 0.5rem;
opacity: 0.7;
"
>
XML
</button>
<button
type="button"
class="import-tab"
data-import-type="m3u"
style="
background: transparent;
border: none;
color: var(--foreground);
cursor: pointer;
padding: 0.25rem 0.5rem;
opacity: 0.7;
"
>
M3U
</button>
</div> </div>
<div id="csv-import-panel" class="import-panel active"> <div id="csv-import-panel" class="import-panel active">
@ -517,6 +562,54 @@
/> />
</div> </div>
<div id="xspf-import-panel" class="import-panel" style="display: none">
<p style="margin-bottom: 0.5rem; font-size: 0.9rem">Import from XSPF</p>
<p style="font-size: 0.8rem; margin: 0">
XSPF (XML Shareable Playlist Format) is a standard XML playlist format supported by many
media players including VLC, Audacious, and Clementine.
</p>
<br />
<input
type="file"
id="xspf-file-input"
class="btn-secondary"
accept=".xspf,.xml"
style="width: 100%; margin-bottom: 0.5rem"
/>
</div>
<div id="xml-import-panel" class="import-panel" style="display: none">
<p style="margin-bottom: 0.5rem; font-size: 0.9rem">Import from XML</p>
<p style="font-size: 0.8rem; margin: 0">
Import playlists from generic XML formats including iTunes XML playlists, Winamp XML, and
other custom XML playlist formats.
</p>
<br />
<input
type="file"
id="xml-file-input"
class="btn-secondary"
accept=".xml"
style="width: 100%; margin-bottom: 0.5rem"
/>
</div>
<div id="m3u-import-panel" class="import-panel" style="display: none">
<p style="margin-bottom: 0.5rem; font-size: 0.9rem">Import from M3U/M3U8</p>
<p style="font-size: 0.8rem; margin: 0">
M3U is the most widely supported playlist format. Import from any M3U or M3U8 playlist file.
Track information is extracted from the extended M3U format.
</p>
<br />
<input
type="file"
id="m3u-file-input"
class="btn-secondary"
accept=".m3u,.m3u8"
style="width: 100%; margin-bottom: 0.5rem"
/>
</div>
<p style="font-size: 0.8rem; margin: 0"> <p style="font-size: 0.8rem; margin: 0">
<b>Warning:</b> This feature isn't perfect and is prone to errors! Please check your playlist <b>Warning:</b> 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. after to remove any unwanted songs that were added by the system.

View file

@ -530,10 +530,27 @@ export function trackImportJSPF(playlistName, trackCount, missingCount, source)
}); });
} }
export function trackExportPlaylist(playlist) { export function trackImportXSPF(playlistName, trackCount, missingCount) {
trackEvent('Export Playlist', { trackEvent('Import XSPF', {
playlist_name: playlist?.title || playlist?.name || 'Unknown', playlist_name: playlistName,
track_count: playlist?.tracks?.length || 0, 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,
}); });
} }

708
js/app.js
View file

@ -29,6 +29,10 @@ import {
trackCreatePlaylist, trackCreatePlaylist,
trackCreateFolder, trackCreateFolder,
trackImportJSPF, trackImportJSPF,
trackImportCSV,
trackImportXSPF,
trackImportXML,
trackImportM3U,
trackSelectLocalFolder, trackSelectLocalFolder,
trackChangeLocalFolder, trackChangeLocalFolder,
trackOpenModal, trackOpenModal,
@ -41,6 +45,7 @@ import {
trackOpenLyrics, trackOpenLyrics,
trackCloseLyrics, trackCloseLyrics,
} from './analytics.js'; } from './analytics.js';
import { parseCSV, parseJSPF, parseXSPF, parseXML, parseM3U } from './playlist-importer.js';
// Lazy-loaded modules // Lazy-loaded modules
let settingsModule = null; let settingsModule = null;
@ -502,13 +507,21 @@ document.addEventListener('DOMContentLoaded', async () => {
// Show/hide panels // Show/hide panels
document.getElementById('csv-import-panel').style.display = importType === 'csv' ? 'block' : 'none'; document.getElementById('csv-import-panel').style.display = importType === 'csv' ? 'block' : 'none';
document.getElementById('jspf-import-panel').style.display = importType === 'jspf' ? '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 // Clear all file inputs except the active one
if (importType === 'csv') { document.getElementById('csv-file-input').value =
document.getElementById('jspf-file-input').value = ''; importType === 'csv' ? document.getElementById('csv-file-input').value : '';
} else { document.getElementById('jspf-file-input').value =
document.getElementById('csv-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('import-section').style.display = 'block';
document.getElementById('csv-file-input').value = ''; document.getElementById('csv-file-input').value = '';
document.getElementById('jspf-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 // Reset import tabs to CSV
document.querySelectorAll('.import-tab').forEach((tab) => { document.querySelectorAll('.import-tab').forEach((tab) => {
@ -748,6 +764,9 @@ document.addEventListener('DOMContentLoaded', async () => {
}); });
document.getElementById('csv-import-panel').style.display = 'block'; document.getElementById('csv-import-panel').style.display = 'block';
document.getElementById('jspf-import-panel').style.display = 'none'; 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 // Reset Public Toggle
const publicToggle = document.getElementById('playlist-public-toggle'); const publicToggle = document.getElementById('playlist-public-toggle');
@ -848,23 +867,45 @@ document.addEventListener('DOMContentLoaded', async () => {
// Create // Create
const csvFileInput = document.getElementById('csv-file-input'); const csvFileInput = document.getElementById('csv-file-input');
const jspfFileInput = document.getElementById('jspf-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 tracks = [];
let importSource = 'manual'; let importSource = 'manual';
let cover = document.getElementById('playlist-cover-input').value.trim(); let cover = document.getElementById('playlist-cover-input').value.trim();
if (jspfFileInput.files.length > 0) { // Helper function for import progress
// Import from JSPF const setupProgressElements = () => {
importSource = 'jspf_import';
const file = jspfFileInput.files[0];
const progressElement = document.getElementById('csv-import-progress'); const progressElement = document.getElementById('csv-import-progress');
const progressFill = document.getElementById('csv-progress-fill'); const progressFill = document.getElementById('csv-progress-fill');
const progressCurrent = document.getElementById('csv-progress-current'); const progressCurrent = document.getElementById('csv-progress-current');
const progressTotal = document.getElementById('csv-progress-total'); const progressTotal = document.getElementById('csv-progress-total');
const currentTrackElement = progressElement.querySelector('.current-track'); const currentTrackElement = progressElement.querySelector('.current-track');
const currentArtistElement = progressElement.querySelector('.current-artist'); 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 { try {
// Show progress bar
progressElement.style.display = 'block'; progressElement.style.display = 'block';
progressFill.style.width = '0%'; progressFill.style.width = '0%';
progressCurrent.textContent = '0'; progressCurrent.textContent = '0';
@ -921,7 +962,6 @@ document.addEventListener('DOMContentLoaded', async () => {
jspfCreator jspfCreator
); );
// if theres missing songs, warn the user
if (missingTracks.length > 0) { if (missingTracks.length > 0) {
setTimeout(() => { setTimeout(() => {
showMissingTracksNotification(missingTracks); showMissingTracksNotification(missingTracks);
@ -933,7 +973,6 @@ document.addEventListener('DOMContentLoaded', async () => {
progressElement.style.display = 'none'; progressElement.style.display = 'none';
return; return;
} finally { } finally {
// Hide progress bar
setTimeout(() => { setTimeout(() => {
progressElement.style.display = 'none'; progressElement.style.display = 'none';
}, 1000); }, 1000);
@ -942,15 +981,16 @@ document.addEventListener('DOMContentLoaded', async () => {
// Import from CSV // Import from CSV
importSource = 'csv_import'; importSource = 'csv_import';
const file = csvFileInput.files[0]; const file = csvFileInput.files[0];
const progressElement = document.getElementById('csv-import-progress'); const {
const progressFill = document.getElementById('csv-progress-fill'); progressElement,
const progressCurrent = document.getElementById('csv-progress-current'); progressFill,
const progressTotal = document.getElementById('csv-progress-total'); progressCurrent,
const currentTrackElement = progressElement.querySelector('.current-track'); progressTotal,
const currentArtistElement = progressElement.querySelector('.current-artist'); currentTrackElement,
currentArtistElement,
} = setupProgressElements();
try { try {
// Show progress bar
progressElement.style.display = 'block'; progressElement.style.display = 'block';
progressFill.style.width = '0%'; progressFill.style.width = '0%';
progressCurrent.textContent = '0'; progressCurrent.textContent = '0';
@ -981,7 +1021,8 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
console.log(`Imported ${tracks.length} tracks from CSV`); 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) { if (missingTracks.length > 0) {
setTimeout(() => { setTimeout(() => {
showMissingTracksNotification(missingTracks); showMissingTracksNotification(missingTracks);
@ -993,7 +1034,183 @@ document.addEventListener('DOMContentLoaded', async () => {
progressElement.style.display = 'none'; progressElement.style.display = 'none';
return; return;
} finally { } 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(() => { setTimeout(() => {
progressElement.style.display = 'none'; progressElement.style.display = 'none';
}, 1000); }, 1000);
@ -1789,453 +2006,6 @@ function showMissingTracksNotification(missingTracks) {
modal.classList.add('active'); 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) { function showDiscographyDownloadModal(artist, api, quality, lyricsManager, triggerBtn) {
const modal = document.getElementById('discography-download-modal'); const modal = document.getElementById('discography-download-modal');

494
js/playlist-importer.js Normal file
View file

@ -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 = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<playlist xmlns="http://xspf.org/ns/0/" version="1">\n';
xml += ` <title>${escapeXml(playlist.title || 'Unknown Playlist')}</title>\n`;
xml += ` <creator>${escapeXml(playlist.artist || 'Various Artists')}</creator>\n`;
xml += ` <date>${date}</date>\n`;
xml += ' <trackList>\n';
tracks.forEach((track) => {
xml += ' <track>\n';
xml += ` <title>${escapeXml(track.title || 'Unknown Title')}</title>\n`;
xml += ` <creator>${escapeXml(getTrackArtists(track))}</creator>\n`;
if (track.album?.title) {
xml += ` <album>${escapeXml(track.album.title)}</album>\n`;
}
if (track.duration) {
xml += ` <duration>${Math.round(track.duration * 1000)}</duration>\n`;
}
xml += ' </track>\n';
});
xml += ' </trackList>\n';
xml += '</playlist>\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 = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<playlist>\n';
xml += ` <name>${escapeXml(playlist.title || 'Unknown Playlist')}</name>\n`;
xml += ` <creator>${escapeXml(playlist.artist || 'Various Artists')}</creator>\n`;
xml += ` <created>${date}</created>\n`;
xml += ` <trackCount>${tracks.length}</trackCount>\n`;
xml += ' <tracks>\n';
tracks.forEach((track, index) => {
xml += ' <track>\n';
xml += ` <position>${index + 1}</position>\n`;
xml += ` <title>${escapeXml(track.title || '')}</title>\n`;
xml += ` <artist>${escapeXml(getTrackArtists(track) || '')}</artist>\n`;
xml += ` <album>${escapeXml(track.album?.title || '')}</album>\n`;
xml += ` <duration>${Math.round(track.duration || 0)}</duration>\n`;
xml += ' </track>\n';
});
xml += ' </tracks>\n';
xml += '</playlist>\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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Export all functions
export { getTrackArtists };