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
</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 id="csv-import-panel" class="import-panel active">
@ -517,6 +562,54 @@
/>
</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">
<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.

View file

@ -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,
});
}

708
js/app.js
View file

@ -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');

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 };