Merge branch 'main' of github.com:monochrome-music/monochrome
This commit is contained in:
commit
38434f5419
16 changed files with 1055 additions and 153 deletions
10
README.md
10
README.md
|
|
@ -218,3 +218,13 @@ We welcome contributions from the community! Please see our [Contributing Guide]
|
|||
<p align="center">
|
||||
Made with ❤️ by the Monochrome team
|
||||
</p>
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#monochrome-music/monochrome&type=date&logscale&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=monochrome-music/monochrome&type=date&theme=dark&logscale&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=monochrome-music/monochrome&type=date&logscale&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=monochrome-music/monochrome&type=date&logscale&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ export async function onRequest(context) {
|
|||
const uploaded = form.get('file');
|
||||
if (!uploaded) return jsonError('No file provided', 400);
|
||||
|
||||
if (uploaded.size > 500 * 1024 * 1024) {
|
||||
return jsonError('File exceeds 500MB', 400);
|
||||
if (uploaded.size > 100 * 1024 * 1024) {
|
||||
return jsonError('File exceeds 100MB', 400);
|
||||
}
|
||||
|
||||
file = await uploaded.arrayBuffer();
|
||||
|
|
|
|||
39
index.html
39
index.html
|
|
@ -2600,8 +2600,31 @@
|
|||
id="playlist-section-recommended"
|
||||
style="display: none; margin-top: 3rem"
|
||||
>
|
||||
<h2 class="section-title">Recommended Songs</h2>
|
||||
<p style="color: grey; margin-bottom: 15px">Suggested Songs From Your Playlist</p>
|
||||
<div class="section-header-row">
|
||||
<div>
|
||||
<h2 class="section-title">Recommended Songs</h2>
|
||||
<p style="color: grey; margin-bottom: 15px">Suggested Songs From Your Playlist</p>
|
||||
</div>
|
||||
<button
|
||||
id="refresh-recommended-songs-btn"
|
||||
class="btn-secondary"
|
||||
title="Refresh Recommendations"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="track-list" id="playlist-detail-recommended"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
|
@ -4643,6 +4666,18 @@
|
|||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Separate Discs in ZIP</span>
|
||||
<span class="description"
|
||||
>Put tracks in Disc folders when a release has multiple discs</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="separate-discs-zip-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
18
js/api.js
18
js/api.js
|
|
@ -957,15 +957,25 @@ export class LosslessAPI {
|
|||
const recommendedTracks = [];
|
||||
const seenTrackIds = new Set(tracks.map((t) => t.id));
|
||||
|
||||
const artistsToProcess = artists.slice(0, Math.min(5, artists.length));
|
||||
// Shuffle artists if refreshing to get different results
|
||||
let shuffledArtists = artists;
|
||||
if (options.refresh) {
|
||||
shuffledArtists = [...artists].sort(() => Math.random() - 0.5);
|
||||
}
|
||||
|
||||
const artistsToProcess = shuffledArtists.slice(0, Math.min(5, shuffledArtists.length));
|
||||
|
||||
const artistPromises = artistsToProcess.map(async (artist) => {
|
||||
try {
|
||||
console.log(`Fetching tracks for artist: ${artist.name} (ID: ${artist.id})`);
|
||||
const artistData = await this.getArtist(artist.id, { lightweight: true, skipCache: options.skipCache });
|
||||
const artistData = await this.getArtist(artist.id, { lightweight: true, skipCache: options.refresh });
|
||||
if (artistData && artistData.tracks && artistData.tracks.length > 0) {
|
||||
const newTracks = artistData.tracks.filter((track) => !seenTrackIds.has(track.id)).slice(0, 4);
|
||||
return newTracks;
|
||||
const availableTracks = artistData.tracks.filter((track) => !seenTrackIds.has(track.id));
|
||||
// Shuffle and pick different tracks when refreshing
|
||||
const shuffled = options.refresh
|
||||
? availableTracks.sort(() => Math.random() - 0.5)
|
||||
: availableTracks;
|
||||
return shuffled.slice(0, 4);
|
||||
} else {
|
||||
console.warn(`No tracks found for artist ${artist.name}`);
|
||||
return [];
|
||||
|
|
|
|||
137
js/app.js
137
js/app.js
|
|
@ -49,7 +49,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;
|
||||
|
|
@ -840,7 +848,74 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
if (e.target.closest('#shuffle-artist-btn')) {
|
||||
const btn = e.target.closest('#shuffle-artist-btn');
|
||||
if (btn.disabled) return;
|
||||
document.getElementById('play-artist-radio-btn')?.click();
|
||||
const artistId = window.location.pathname.split('/')[2];
|
||||
if (!artistId) return;
|
||||
|
||||
btn.disabled = true;
|
||||
const originalHTML = btn.innerHTML;
|
||||
btn.innerHTML =
|
||||
'<svg class="animate-spin" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle></svg><span>Shuffling...</span>';
|
||||
|
||||
try {
|
||||
const artist = await api.getArtist(artistId);
|
||||
const allReleases = [...(artist.albums || []), ...(artist.eps || [])];
|
||||
const trackSet = new Set();
|
||||
const allTracks = [];
|
||||
|
||||
// Fetch full artist discography tracks (albums + EPs), deduped by track ID.
|
||||
const chunkSize = 8;
|
||||
for (let i = 0; i < allReleases.length; i += chunkSize) {
|
||||
const chunk = allReleases.slice(i, i + chunkSize);
|
||||
await Promise.all(
|
||||
chunk.map(async (album) => {
|
||||
try {
|
||||
const { tracks } = await api.getAlbum(album.id);
|
||||
tracks.forEach((track) => {
|
||||
if (!trackSet.has(track.id)) {
|
||||
trackSet.add(track.id);
|
||||
allTracks.push(track);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`Failed to fetch tracks for album ${album.title}:`, err);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to artist top tracks if discography fetch yields nothing.
|
||||
if (allTracks.length === 0 && Array.isArray(artist.tracks)) {
|
||||
artist.tracks.forEach((track) => {
|
||||
if (!trackSet.has(track.id)) {
|
||||
trackSet.add(track.id);
|
||||
allTracks.push(track);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (allTracks.length === 0) {
|
||||
throw new Error('No tracks found for this artist');
|
||||
}
|
||||
|
||||
const shuffledTracks = [...allTracks].sort(() => Math.random() - 0.5);
|
||||
player.setQueue(shuffledTracks, 0);
|
||||
const shuffleBtn = document.getElementById('shuffle-btn');
|
||||
if (shuffleBtn) shuffleBtn.classList.remove('active');
|
||||
player.shuffleActive = false;
|
||||
player.playTrackFromQueue();
|
||||
|
||||
const { showNotification } = await loadDownloadsModule();
|
||||
showNotification('Shuffling artist discography');
|
||||
} catch (error) {
|
||||
console.error('Failed to shuffle artist tracks:', error);
|
||||
const { showNotification } = await loadDownloadsModule();
|
||||
showNotification('Failed to shuffle artist tracks');
|
||||
} finally {
|
||||
if (document.body.contains(btn)) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHTML;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (e.target.closest('#download-mix-btn')) {
|
||||
const btn = e.target.closest('#download-mix-btn');
|
||||
|
|
@ -1252,8 +1327,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,
|
||||
|
|
@ -1273,20 +1346,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.');
|
||||
|
|
|
|||
259
js/downloads.js
259
js/downloads.js
|
|
@ -31,6 +31,88 @@ async function loadClientZip() {
|
|||
}
|
||||
}
|
||||
|
||||
function toPositiveInt(value) {
|
||||
const parsed = parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function getExplicitTrackDiscNumber(track) {
|
||||
const candidates = [
|
||||
track?.volumeNumber,
|
||||
track?.discNumber,
|
||||
track?.mediaNumber,
|
||||
track?.media_number,
|
||||
track?.volume,
|
||||
track?.disc,
|
||||
track?.volume?.number,
|
||||
track?.disc?.number,
|
||||
track?.media?.number,
|
||||
track?.disc,
|
||||
track?.disc_no,
|
||||
track?.discNo,
|
||||
track?.disc_number,
|
||||
track?.mediaMetadata?.discNumber,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const parsed = toPositiveInt(candidate);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function createDiscLayoutContext(tracks, api) {
|
||||
if (!playlistSettings.shouldSeparateDiscsInZip()) {
|
||||
return { separateByDisc: false, resolveDiscNumber: () => 1 };
|
||||
}
|
||||
|
||||
const explicitDiscNumbers = tracks.map((track) => getExplicitTrackDiscNumber(track));
|
||||
const explicitDistinct = new Set(explicitDiscNumbers.filter(Boolean));
|
||||
|
||||
if (explicitDistinct.size > 1) {
|
||||
return {
|
||||
separateByDisc: true,
|
||||
resolveDiscNumber: (index) => explicitDiscNumbers[index] || 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Some providers omit disc fields in album payload but include them in full track metadata.
|
||||
const hydratedDiscNumbers = await Promise.all(
|
||||
tracks.map(async (track, index) => {
|
||||
if (explicitDiscNumbers[index]) return explicitDiscNumbers[index];
|
||||
try {
|
||||
const fullTrack = await api.getTrackMetadata(track.id);
|
||||
return getExplicitTrackDiscNumber(fullTrack);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const hydratedDistinct = new Set(hydratedDiscNumbers.filter(Boolean));
|
||||
if (hydratedDistinct.size > 1) {
|
||||
return {
|
||||
separateByDisc: true,
|
||||
resolveDiscNumber: (index) => hydratedDiscNumbers[index] || explicitDiscNumbers[index] || 1,
|
||||
};
|
||||
}
|
||||
|
||||
return { separateByDisc: false, resolveDiscNumber: () => 1 };
|
||||
}
|
||||
|
||||
function getDiscFolderName(discNumber) {
|
||||
return `Disc ${discNumber}`;
|
||||
}
|
||||
|
||||
function buildZipTrackPath(rootFolder, filename, separateByDisc, discNumber = 1) {
|
||||
if (!separateByDisc) return `${rootFolder}/${filename}`;
|
||||
return `${rootFolder}/${getDiscFolderName(discNumber)}/${filename}`;
|
||||
}
|
||||
|
||||
function getPlaylistAudioExtension(quality) {
|
||||
return quality === 'LOW' || quality === 'HIGH' ? 'm4a' : 'flac';
|
||||
}
|
||||
|
||||
function createDownloadNotification() {
|
||||
if (!downloadNotificationContainer) {
|
||||
downloadNotificationContainer = document.createElement('div');
|
||||
|
|
@ -190,6 +272,26 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
|
|||
artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null),
|
||||
};
|
||||
|
||||
try {
|
||||
const fullTrack = await api.getTrackMetadata(track.id);
|
||||
if (fullTrack) {
|
||||
enrichedTrack = {
|
||||
...fullTrack,
|
||||
...enrichedTrack,
|
||||
artist: enrichedTrack.artist || fullTrack.artist,
|
||||
album: {
|
||||
...(fullTrack.album || {}),
|
||||
...(enrichedTrack.album || {}),
|
||||
},
|
||||
// Preserve explicit disc fields from either source
|
||||
discNumber: enrichedTrack.discNumber ?? fullTrack.discNumber,
|
||||
volumeNumber: enrichedTrack.volumeNumber ?? fullTrack.volumeNumber,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: continue with best available track payload
|
||||
}
|
||||
|
||||
if (enrichedTrack.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist) && enrichedTrack.album.id) {
|
||||
try {
|
||||
const albumData = await api.getAlbum(enrichedTrack.album.id);
|
||||
|
|
@ -323,9 +425,21 @@ async function bulkDownloadToZipStream(
|
|||
|
||||
// Generate playlist files first
|
||||
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
|
||||
const playlistAudioExtension = getPlaylistAudioExtension(quality);
|
||||
const discLayout = await createDiscLayoutContext(tracks, api);
|
||||
const separateByDisc = discLayout.separateByDisc;
|
||||
const playlistPathResolver = separateByDisc
|
||||
? (_track, filename, index) => `${getDiscFolderName(discLayout.resolveDiscNumber(index))}/${filename}`
|
||||
: null;
|
||||
|
||||
if (playlistSettings.shouldGenerateM3U()) {
|
||||
const m3uContent = generateM3U(metadata || { title: folderName }, tracks, useRelativePaths);
|
||||
const m3uContent = generateM3U(
|
||||
metadata || { title: folderName },
|
||||
tracks,
|
||||
useRelativePaths,
|
||||
playlistPathResolver,
|
||||
playlistAudioExtension
|
||||
);
|
||||
yield {
|
||||
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u`,
|
||||
lastModified: new Date(),
|
||||
|
|
@ -334,7 +448,13 @@ async function bulkDownloadToZipStream(
|
|||
}
|
||||
|
||||
if (playlistSettings.shouldGenerateM3U8()) {
|
||||
const m3u8Content = generateM3U8(metadata || { title: folderName }, tracks, useRelativePaths);
|
||||
const m3u8Content = generateM3U8(
|
||||
metadata || { title: folderName },
|
||||
tracks,
|
||||
useRelativePaths,
|
||||
playlistPathResolver,
|
||||
playlistAudioExtension
|
||||
);
|
||||
yield {
|
||||
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`,
|
||||
lastModified: new Date(),
|
||||
|
|
@ -382,7 +502,12 @@ async function bulkDownloadToZipStream(
|
|||
try {
|
||||
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal);
|
||||
const filename = buildTrackFilename(track, quality, extension);
|
||||
yield { name: `${folderName}/${filename}`, lastModified: new Date(), input: blob };
|
||||
const discNumber = discLayout.resolveDiscNumber(i);
|
||||
yield {
|
||||
name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber),
|
||||
lastModified: new Date(),
|
||||
input: blob,
|
||||
};
|
||||
|
||||
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
||||
try {
|
||||
|
|
@ -392,7 +517,7 @@ async function bulkDownloadToZipStream(
|
|||
if (lrcContent) {
|
||||
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
||||
yield {
|
||||
name: `${folderName}/${lrcFilename}`,
|
||||
name: buildZipTrackPath(folderName, lrcFilename, separateByDisc, discNumber),
|
||||
lastModified: new Date(),
|
||||
input: lrcContent,
|
||||
};
|
||||
|
|
@ -442,9 +567,21 @@ async function bulkDownloadToZipBlob(
|
|||
|
||||
// Generate playlist files first
|
||||
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
|
||||
const playlistAudioExtension = getPlaylistAudioExtension(quality);
|
||||
const discLayout = await createDiscLayoutContext(tracks, api);
|
||||
const separateByDisc = discLayout.separateByDisc;
|
||||
const playlistPathResolver = separateByDisc
|
||||
? (_track, filename, index) => `${getDiscFolderName(discLayout.resolveDiscNumber(index))}/${filename}`
|
||||
: null;
|
||||
|
||||
if (playlistSettings.shouldGenerateM3U()) {
|
||||
const m3uContent = generateM3U(metadata || { title: folderName }, tracks, useRelativePaths);
|
||||
const m3uContent = generateM3U(
|
||||
metadata || { title: folderName },
|
||||
tracks,
|
||||
useRelativePaths,
|
||||
playlistPathResolver,
|
||||
playlistAudioExtension
|
||||
);
|
||||
yield {
|
||||
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u`,
|
||||
lastModified: new Date(),
|
||||
|
|
@ -453,7 +590,13 @@ async function bulkDownloadToZipBlob(
|
|||
}
|
||||
|
||||
if (playlistSettings.shouldGenerateM3U8()) {
|
||||
const m3u8Content = generateM3U8(metadata || { title: folderName }, tracks, useRelativePaths);
|
||||
const m3u8Content = generateM3U8(
|
||||
metadata || { title: folderName },
|
||||
tracks,
|
||||
useRelativePaths,
|
||||
playlistPathResolver,
|
||||
playlistAudioExtension
|
||||
);
|
||||
yield {
|
||||
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`,
|
||||
lastModified: new Date(),
|
||||
|
|
@ -501,7 +644,12 @@ async function bulkDownloadToZipBlob(
|
|||
try {
|
||||
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal);
|
||||
const filename = buildTrackFilename(track, quality, extension);
|
||||
yield { name: `${folderName}/${filename}`, lastModified: new Date(), input: blob };
|
||||
const discNumber = discLayout.resolveDiscNumber(i);
|
||||
yield {
|
||||
name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber),
|
||||
lastModified: new Date(),
|
||||
input: blob,
|
||||
};
|
||||
|
||||
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
||||
try {
|
||||
|
|
@ -511,7 +659,7 @@ async function bulkDownloadToZipBlob(
|
|||
if (lrcContent) {
|
||||
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
||||
yield {
|
||||
name: `${folderName}/${lrcFilename}`,
|
||||
name: buildZipTrackPath(folderName, lrcFilename, separateByDisc, discNumber),
|
||||
lastModified: new Date(),
|
||||
input: lrcContent,
|
||||
};
|
||||
|
|
@ -562,9 +710,21 @@ async function bulkDownloadToZipNeutralino(
|
|||
|
||||
// Generate playlist files first
|
||||
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
|
||||
const playlistAudioExtension = getPlaylistAudioExtension(quality);
|
||||
const discLayout = await createDiscLayoutContext(tracks, api);
|
||||
const separateByDisc = discLayout.separateByDisc;
|
||||
const playlistPathResolver = separateByDisc
|
||||
? (_track, filename, index) => `${getDiscFolderName(discLayout.resolveDiscNumber(index))}/${filename}`
|
||||
: null;
|
||||
|
||||
if (playlistSettings.shouldGenerateM3U()) {
|
||||
const m3uContent = generateM3U(metadata || { title: folderName }, tracks, useRelativePaths);
|
||||
const m3uContent = generateM3U(
|
||||
metadata || { title: folderName },
|
||||
tracks,
|
||||
useRelativePaths,
|
||||
playlistPathResolver,
|
||||
playlistAudioExtension
|
||||
);
|
||||
yield {
|
||||
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u`,
|
||||
lastModified: new Date(),
|
||||
|
|
@ -573,7 +733,13 @@ async function bulkDownloadToZipNeutralino(
|
|||
}
|
||||
|
||||
if (playlistSettings.shouldGenerateM3U8()) {
|
||||
const m3u8Content = generateM3U8(metadata || { title: folderName }, tracks, useRelativePaths);
|
||||
const m3u8Content = generateM3U8(
|
||||
metadata || { title: folderName },
|
||||
tracks,
|
||||
useRelativePaths,
|
||||
playlistPathResolver,
|
||||
playlistAudioExtension
|
||||
);
|
||||
yield {
|
||||
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`,
|
||||
lastModified: new Date(),
|
||||
|
|
@ -621,7 +787,12 @@ async function bulkDownloadToZipNeutralino(
|
|||
try {
|
||||
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal);
|
||||
const filename = buildTrackFilename(track, quality, extension);
|
||||
yield { name: `${folderName}/${filename}`, lastModified: new Date(), input: blob };
|
||||
const discNumber = discLayout.resolveDiscNumber(i);
|
||||
yield {
|
||||
name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber),
|
||||
lastModified: new Date(),
|
||||
input: blob,
|
||||
};
|
||||
|
||||
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
||||
try {
|
||||
|
|
@ -631,7 +802,7 @@ async function bulkDownloadToZipNeutralino(
|
|||
if (lrcContent) {
|
||||
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
||||
yield {
|
||||
name: `${folderName}/${lrcFilename}`,
|
||||
name: buildZipTrackPath(folderName, lrcFilename, separateByDisc, discNumber),
|
||||
lastModified: new Date(),
|
||||
input: lrcContent,
|
||||
};
|
||||
|
|
@ -718,8 +889,9 @@ async function startBulkDownload(
|
|||
const isNeutralino = window.NL_MODE === true;
|
||||
const hasFileSystemAccess =
|
||||
'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype;
|
||||
const useZip = hasFileSystemAccess && !bulkDownloadSettings.shouldForceIndividual();
|
||||
const useZipBlob = !hasFileSystemAccess && !bulkDownloadSettings.shouldForceIndividual();
|
||||
const forceIndividual = bulkDownloadSettings.shouldForceIndividual();
|
||||
const useZip = hasFileSystemAccess && !forceIndividual;
|
||||
const useZipBlob = !hasFileSystemAccess && !forceIndividual;
|
||||
|
||||
if (isNeutralino) {
|
||||
// Neutralino Native Logic
|
||||
|
|
@ -871,9 +1043,22 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
|||
|
||||
// Generate playlist files for each album
|
||||
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
|
||||
const playlistAudioExtension = getPlaylistAudioExtension(quality);
|
||||
const discLayout = await createDiscLayoutContext(tracks, api);
|
||||
const separateByDisc = discLayout.separateByDisc;
|
||||
const playlistPathResolver = separateByDisc
|
||||
? (_track, filename, index) =>
|
||||
`${getDiscFolderName(discLayout.resolveDiscNumber(index))}/${filename}`
|
||||
: null;
|
||||
|
||||
if (playlistSettings.shouldGenerateM3U()) {
|
||||
const m3uContent = generateM3U(fullAlbum, tracks, useRelativePaths);
|
||||
const m3uContent = generateM3U(
|
||||
fullAlbum,
|
||||
tracks,
|
||||
useRelativePaths,
|
||||
playlistPathResolver,
|
||||
playlistAudioExtension
|
||||
);
|
||||
yield {
|
||||
name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.m3u`,
|
||||
lastModified: new Date(),
|
||||
|
|
@ -882,7 +1067,13 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
|||
}
|
||||
|
||||
if (playlistSettings.shouldGenerateM3U8()) {
|
||||
const m3u8Content = generateM3U8(fullAlbum, tracks, useRelativePaths);
|
||||
const m3u8Content = generateM3U8(
|
||||
fullAlbum,
|
||||
tracks,
|
||||
useRelativePaths,
|
||||
playlistPathResolver,
|
||||
playlistAudioExtension
|
||||
);
|
||||
yield {
|
||||
name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.m3u8`,
|
||||
lastModified: new Date(),
|
||||
|
|
@ -918,12 +1109,18 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
|||
};
|
||||
}
|
||||
|
||||
for (const track of tracks) {
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
const track = tracks[i];
|
||||
if (signal.aborted) break;
|
||||
try {
|
||||
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal);
|
||||
const filename = buildTrackFilename(track, quality, extension);
|
||||
yield { name: `${fullFolderPath}/${filename}`, lastModified: new Date(), input: blob };
|
||||
const discNumber = discLayout.resolveDiscNumber(i);
|
||||
yield {
|
||||
name: buildZipTrackPath(fullFolderPath, filename, separateByDisc, discNumber),
|
||||
lastModified: new Date(),
|
||||
input: blob,
|
||||
};
|
||||
|
||||
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
||||
try {
|
||||
|
|
@ -933,7 +1130,12 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
|||
if (lrcContent) {
|
||||
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
||||
yield {
|
||||
name: `${fullFolderPath}/${lrcFilename}`,
|
||||
name: buildZipTrackPath(
|
||||
fullFolderPath,
|
||||
lrcFilename,
|
||||
separateByDisc,
|
||||
discNumber
|
||||
),
|
||||
lastModified: new Date(),
|
||||
input: lrcContent,
|
||||
};
|
||||
|
|
@ -1097,6 +1299,25 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
|
|||
artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null),
|
||||
};
|
||||
|
||||
try {
|
||||
const fullTrack = await api.getTrackMetadata(track.id);
|
||||
if (fullTrack) {
|
||||
enrichedTrack = {
|
||||
...fullTrack,
|
||||
...enrichedTrack,
|
||||
artist: enrichedTrack.artist || fullTrack.artist,
|
||||
album: {
|
||||
...(fullTrack.album || {}),
|
||||
...(enrichedTrack.album || {}),
|
||||
},
|
||||
discNumber: enrichedTrack.discNumber ?? fullTrack.discNumber,
|
||||
volumeNumber: enrichedTrack.volumeNumber ?? fullTrack.volumeNumber,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Continue with available track payload
|
||||
}
|
||||
|
||||
if (enrichedTrack.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist) && enrichedTrack.album.id) {
|
||||
try {
|
||||
const albumData = await api.getAlbum(enrichedTrack.album.id);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,40 @@ const DEFAULT_TITLE = 'Unknown Title';
|
|||
const DEFAULT_ARTIST = 'Unknown Artist';
|
||||
const DEFAULT_ALBUM = 'Unknown Album';
|
||||
|
||||
/**
|
||||
* Builds a full artist string by combining the track's listed artists
|
||||
* with any featured artists parsed from the title (feat./with).
|
||||
*/
|
||||
function getFullArtistString(track) {
|
||||
const knownArtists =
|
||||
Array.isArray(track.artists) && track.artists.length > 0
|
||||
? track.artists.map((a) => (typeof a === 'string' ? a : a.name) || '').filter(Boolean)
|
||||
: track.artist?.name
|
||||
? [track.artist.name]
|
||||
: [];
|
||||
|
||||
// Parse featured artists from title, e.g. "Song (feat. A, B & C)" or "(with X & Y)"
|
||||
// Note: splitting on '&' may incorrectly fragment compound artist names like "Simon & Garfunkel".
|
||||
const featPattern = /\(\s*(?:feat\.?|ft\.?|with)\s+(.+?)\s*\)/gi;
|
||||
const allFeatArtists = [...(track.title?.matchAll(featPattern) ?? [])].flatMap((m) =>
|
||||
m[1]
|
||||
.split(/\s*[,&]\s*/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
if (allFeatArtists.length > 0) {
|
||||
const knownLower = new Set(knownArtists.map((n) => n.toLowerCase()));
|
||||
for (const feat of allFeatArtists) {
|
||||
if (!knownLower.has(feat.toLowerCase())) {
|
||||
knownArtists.push(feat);
|
||||
knownLower.add(feat.toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return knownArtists.join('; ') || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds metadata tags to audio files (FLAC or M4A)
|
||||
* @param {Blob} audioBlob - The audio file blob
|
||||
|
|
@ -544,13 +578,15 @@ function parseFlacBlocks(dataView) {
|
|||
function createVorbisCommentBlock(track) {
|
||||
// Vorbis comment structure
|
||||
const comments = [];
|
||||
const discNumber = track.volumeNumber ?? track.discNumber;
|
||||
|
||||
// Add standard tags
|
||||
if (track.title) {
|
||||
comments.push(['TITLE', track.title]);
|
||||
}
|
||||
if (track.artist?.name) {
|
||||
comments.push(['ARTIST', track.artist.name]);
|
||||
const artistStr = getFullArtistString(track);
|
||||
if (artistStr) {
|
||||
comments.push(['ARTIST', artistStr]);
|
||||
}
|
||||
if (track.album?.title) {
|
||||
comments.push(['ALBUM', track.album.title]);
|
||||
|
|
@ -562,6 +598,9 @@ function createVorbisCommentBlock(track) {
|
|||
if (track.trackNumber) {
|
||||
comments.push(['TRACKNUMBER', String(track.trackNumber)]);
|
||||
}
|
||||
if (discNumber) {
|
||||
comments.push(['DISCNUMBER', String(discNumber)]);
|
||||
}
|
||||
if (track.album?.numberOfTracks) {
|
||||
comments.push(['TRACKTOTAL', String(track.album.numberOfTracks)]);
|
||||
}
|
||||
|
|
@ -906,7 +945,7 @@ function createMp4MetadataAtoms(track) {
|
|||
|
||||
const tags = {
|
||||
'©nam': track.title || DEFAULT_TITLE,
|
||||
'©ART': track.artist?.name || DEFAULT_ARTIST,
|
||||
'©ART': getFullArtistString(track) || DEFAULT_ARTIST,
|
||||
'©alb': track.album?.title || DEFAULT_ALBUM,
|
||||
aART: track.album?.artist?.name || track.artist?.name || DEFAULT_ARTIST,
|
||||
};
|
||||
|
|
@ -920,7 +959,18 @@ function createMp4MetadataAtoms(track) {
|
|||
}
|
||||
|
||||
if (track.trackNumber) {
|
||||
tags['trkn'] = track.trackNumber;
|
||||
tags['trkn'] = {
|
||||
current: track.trackNumber,
|
||||
total: track.album?.numberOfTracks,
|
||||
};
|
||||
}
|
||||
|
||||
const discNumber = track.volumeNumber ?? track.discNumber;
|
||||
if (discNumber) {
|
||||
tags['disk'] = {
|
||||
current: discNumber,
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const releaseDateStr =
|
||||
|
|
@ -1055,7 +1105,7 @@ function createMetadataBlock(metadataAtoms) {
|
|||
|
||||
// Text tags
|
||||
for (const [key, value] of Object.entries(tags)) {
|
||||
if (key === 'trkn') {
|
||||
if (key === 'trkn' || key === 'disk') {
|
||||
ilstChildren.push(createIntAtom(key, value));
|
||||
} else {
|
||||
ilstChildren.push(createStringAtom(key, value));
|
||||
|
|
@ -1190,7 +1240,7 @@ function createStringAtom(type, value) {
|
|||
}
|
||||
|
||||
function createIntAtom(type, value) {
|
||||
// trkn is special: data is 8 bytes.
|
||||
// trkn/disk are special: data is 8 bytes.
|
||||
// reserved(2) + track(2) + total(2) + reserved(2)
|
||||
const dataSize = 16 + 8;
|
||||
const atomSize = 8 + dataSize;
|
||||
|
|
@ -1214,16 +1264,18 @@ function createIntAtom(type, value) {
|
|||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
|
||||
// Track data
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
// Track num
|
||||
const trk = parseInt(value) || 0;
|
||||
buf[offset++] = (trk >> 8) & 0xff;
|
||||
buf[offset++] = trk & 0xff;
|
||||
// Total (0 for now)
|
||||
const current = typeof value === 'object' ? value.current : value;
|
||||
const total = typeof value === 'object' ? value.total : 0;
|
||||
|
||||
// Numbering payload (track/disc number + total)
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
const numberValue = parseInt(current, 10) || 0;
|
||||
buf[offset++] = (numberValue >> 8) & 0xff;
|
||||
buf[offset++] = numberValue & 0xff;
|
||||
const totalValue = parseInt(total, 10) || 0;
|
||||
buf[offset++] = (totalValue >> 8) & 0xff;
|
||||
buf[offset++] = totalValue & 0xff;
|
||||
buf[offset++] = 0;
|
||||
buf[offset++] = 0;
|
||||
|
||||
|
|
|
|||
|
|
@ -681,7 +681,10 @@ export class Player {
|
|||
tracksToShuffle.splice(this.currentQueueIndex, 1);
|
||||
}
|
||||
|
||||
tracksToShuffle.sort(() => Math.random() - 0.5);
|
||||
for (let i = tracksToShuffle.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[tracksToShuffle[i], tracksToShuffle[j]] = [tracksToShuffle[j], tracksToShuffle[i]];
|
||||
}
|
||||
|
||||
if (currentTrack) {
|
||||
this.shuffledQueue = [currentTrack, ...tracksToShuffle];
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import { sanitizeForFilename } from './utils.js';
|
|||
* @param {Object} playlist - Playlist metadata (title, artist, etc.)
|
||||
* @param {Array} tracks - Array of track objects
|
||||
* @param {boolean} useRelativePaths - Whether to use relative paths
|
||||
* @param {Function|null} pathResolver - Optional resolver for per-track relative path
|
||||
* @param {string} audioExtension - Audio file extension used in generated paths
|
||||
* @returns {string} M3U content
|
||||
*/
|
||||
export function generateM3U(playlist, tracks, useRelativePaths = true) {
|
||||
export function generateM3U(playlist, tracks, useRelativePaths = true, pathResolver = null, audioExtension = 'flac') {
|
||||
let content = '#EXTM3U\n';
|
||||
|
||||
if (playlist.title) {
|
||||
|
|
@ -29,8 +31,9 @@ export function generateM3U(playlist, tracks, useRelativePaths = true) {
|
|||
|
||||
content += `#EXTINF:${duration},${displayName}\n`;
|
||||
|
||||
const filename = getTrackFilename(track, index + 1);
|
||||
const path = useRelativePaths ? filename : filename;
|
||||
const filename = getTrackFilename(track, index + 1, audioExtension);
|
||||
const relativePath = typeof pathResolver === 'function' ? pathResolver(track, filename, index) : filename;
|
||||
const path = useRelativePaths ? relativePath : relativePath;
|
||||
|
||||
content += `${path}\n\n`;
|
||||
});
|
||||
|
|
@ -43,9 +46,11 @@ export function generateM3U(playlist, tracks, useRelativePaths = true) {
|
|||
* @param {Object} playlist - Playlist metadata
|
||||
* @param {Array} tracks - Array of track objects
|
||||
* @param {boolean} useRelativePaths - Whether to use relative paths
|
||||
* @param {Function|null} pathResolver - Optional resolver for per-track relative path
|
||||
* @param {string} audioExtension - Audio file extension used in generated paths
|
||||
* @returns {string} M3U8 content
|
||||
*/
|
||||
export function generateM3U8(playlist, tracks, useRelativePaths = true) {
|
||||
export function generateM3U8(playlist, tracks, useRelativePaths = true, pathResolver = null, audioExtension = 'flac') {
|
||||
let content = '#EXTM3U\n';
|
||||
content += '#EXT-X-VERSION:3\n';
|
||||
content += '#EXT-X-PLAYLIST-TYPE:VOD\n';
|
||||
|
|
@ -72,8 +77,9 @@ export function generateM3U8(playlist, tracks, useRelativePaths = true) {
|
|||
|
||||
content += `#EXTINF:${duration}.000,${displayName}\n`;
|
||||
|
||||
const filename = getTrackFilename(track, index + 1);
|
||||
const path = useRelativePaths ? filename : filename;
|
||||
const filename = getTrackFilename(track, index + 1, audioExtension);
|
||||
const relativePath = typeof pathResolver === 'function' ? pathResolver(track, filename, index) : filename;
|
||||
const path = useRelativePaths ? relativePath : relativePath;
|
||||
|
||||
content += `${path}\n\n`;
|
||||
});
|
||||
|
|
@ -242,7 +248,7 @@ function getTrackArtists(track) {
|
|||
/**
|
||||
* Helper function to get track filename
|
||||
*/
|
||||
function getTrackFilename(track, trackNumber = 1) {
|
||||
function getTrackFilename(track, trackNumber = 1, audioExtension = 'flac') {
|
||||
const paddedNumber = String(trackNumber).padStart(2, '0');
|
||||
const artists = getTrackArtists(track);
|
||||
const title = track.title || 'Unknown Title';
|
||||
|
|
@ -250,7 +256,7 @@ function getTrackFilename(track, trackNumber = 1) {
|
|||
const sanitizedArtists = sanitizeForFilename(artists);
|
||||
const sanitizedTitle = sanitizeForFilename(title);
|
||||
|
||||
return `${paddedNumber} - ${sanitizedArtists} - ${sanitizedTitle}.flac`;
|
||||
return `${paddedNumber} - ${sanitizedArtists} - ${sanitizedTitle}.${audioExtension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -2588,6 +2588,14 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
});
|
||||
}
|
||||
|
||||
const separateDiscsZipToggle = document.getElementById('separate-discs-zip-toggle');
|
||||
if (separateDiscsZipToggle) {
|
||||
separateDiscsZipToggle.checked = playlistSettings.shouldSeparateDiscsInZip();
|
||||
separateDiscsZipToggle.addEventListener('change', (e) => {
|
||||
playlistSettings.setSeparateDiscsInZip(e.target.checked);
|
||||
});
|
||||
}
|
||||
|
||||
// API settings
|
||||
document.getElementById('refresh-speed-test-btn')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('refresh-speed-test-btn');
|
||||
|
|
|
|||
|
|
@ -638,6 +638,7 @@ export const playlistSettings = {
|
|||
NFO_KEY: 'playlist-generate-nfo',
|
||||
JSON_KEY: 'playlist-generate-json',
|
||||
RELATIVE_PATHS_KEY: 'playlist-relative-paths',
|
||||
SEPARATE_DISCS_KEY: 'playlist-separate-discs-in-zip',
|
||||
|
||||
shouldGenerateM3U() {
|
||||
try {
|
||||
|
|
@ -689,6 +690,15 @@ export const playlistSettings = {
|
|||
}
|
||||
},
|
||||
|
||||
shouldSeparateDiscsInZip() {
|
||||
try {
|
||||
const val = localStorage.getItem(this.SEPARATE_DISCS_KEY);
|
||||
return val === null ? true : val === 'true';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
setGenerateM3U(enabled) {
|
||||
localStorage.setItem(this.M3U_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
|
|
@ -712,6 +722,10 @@ export const playlistSettings = {
|
|||
setUseRelativePaths(enabled) {
|
||||
localStorage.setItem(this.RELATIVE_PATHS_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
|
||||
setSeparateDiscsInZip(enabled) {
|
||||
localStorage.setItem(this.SEPARATE_DISCS_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
};
|
||||
|
||||
export const visualizerSettings = {
|
||||
|
|
|
|||
|
|
@ -473,75 +473,94 @@ export function initializeUIInteractions(player, api, ui) {
|
|||
});
|
||||
});
|
||||
|
||||
// Tooltip for truncated text
|
||||
let tooltipEl = document.getElementById('custom-tooltip');
|
||||
if (!tooltipEl) {
|
||||
tooltipEl = document.createElement('div');
|
||||
tooltipEl.id = 'custom-tooltip';
|
||||
document.body.appendChild(tooltipEl);
|
||||
// Tooltip for truncated text (desktop hover only)
|
||||
const canUseHoverTooltips = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
||||
let tooltipEl = null;
|
||||
|
||||
if (canUseHoverTooltips) {
|
||||
tooltipEl = document.getElementById('custom-tooltip');
|
||||
if (!tooltipEl) {
|
||||
tooltipEl = document.createElement('div');
|
||||
tooltipEl.id = 'custom-tooltip';
|
||||
document.body.appendChild(tooltipEl);
|
||||
}
|
||||
|
||||
const updateTooltipPosition = (e) => {
|
||||
const x = e.clientX + 15;
|
||||
const y = e.clientY + 15;
|
||||
|
||||
// Prevent going off-screen
|
||||
const rect = tooltipEl.getBoundingClientRect();
|
||||
const winWidth = window.innerWidth;
|
||||
const winHeight = window.innerHeight;
|
||||
|
||||
let finalX = x;
|
||||
let finalY = y;
|
||||
|
||||
if (x + rect.width > winWidth) {
|
||||
finalX = e.clientX - rect.width - 10;
|
||||
}
|
||||
|
||||
if (y + rect.height > winHeight) {
|
||||
finalY = e.clientY - rect.height - 10;
|
||||
}
|
||||
|
||||
// Ensure it stays within viewport
|
||||
if (finalX < 5) finalX = 5;
|
||||
if (finalY < 5) finalY = 5;
|
||||
if (finalX + rect.width > winWidth - 5) finalX = winWidth - rect.width - 5;
|
||||
if (finalY + rect.height > winHeight - 5) finalY = winHeight - rect.height - 5;
|
||||
|
||||
tooltipEl.style.transform = `translate(${finalX}px, ${finalY}px)`;
|
||||
// Reset top/left to 0 since we use transform
|
||||
tooltipEl.style.top = '0';
|
||||
tooltipEl.style.left = '0';
|
||||
};
|
||||
|
||||
document.body.addEventListener('mouseover', (e) => {
|
||||
const selector =
|
||||
'.card-title, .card-subtitle, .track-item-details .title, .track-item-details .artist, .now-playing-bar .title, .now-playing-bar .artist, .now-playing-bar .album, .pinned-item-name';
|
||||
const target = e.target.closest(selector);
|
||||
|
||||
if (target) {
|
||||
// Remove native title if present to avoid double tooltip
|
||||
if (target.hasAttribute('title')) {
|
||||
target.removeAttribute('title');
|
||||
}
|
||||
|
||||
if (target.scrollWidth > target.clientWidth) {
|
||||
tooltipEl.innerHTML = target.innerHTML.trim();
|
||||
tooltipEl.classList.add('visible');
|
||||
updateTooltipPosition(e);
|
||||
|
||||
const moveHandler = (moveEvent) => {
|
||||
updateTooltipPosition(moveEvent);
|
||||
};
|
||||
|
||||
const outHandler = () => {
|
||||
tooltipEl.classList.remove('visible');
|
||||
target.removeEventListener('mousemove', moveHandler);
|
||||
target.removeEventListener('mouseleave', outHandler);
|
||||
target.removeEventListener('click', outHandler);
|
||||
};
|
||||
|
||||
target.addEventListener('mousemove', moveHandler);
|
||||
target.addEventListener('mouseleave', outHandler);
|
||||
target.addEventListener('click', outHandler);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const updateTooltipPosition = (e) => {
|
||||
const x = e.clientX + 15;
|
||||
const y = e.clientY + 15;
|
||||
|
||||
// Prevent going off-screen
|
||||
const rect = tooltipEl.getBoundingClientRect();
|
||||
const winWidth = window.innerWidth;
|
||||
const winHeight = window.innerHeight;
|
||||
|
||||
let finalX = x;
|
||||
let finalY = y;
|
||||
|
||||
if (x + rect.width > winWidth) {
|
||||
finalX = e.clientX - rect.width - 10;
|
||||
// Hide tooltip and context menu on any click to be safe
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
if (tooltipEl) {
|
||||
tooltipEl.classList.remove('visible');
|
||||
}
|
||||
|
||||
if (y + rect.height > winHeight) {
|
||||
finalY = e.clientY - rect.height - 10;
|
||||
}
|
||||
|
||||
// Ensure it stays within viewport
|
||||
if (finalX < 5) finalX = 5;
|
||||
if (finalY < 5) finalY = 5;
|
||||
if (finalX + rect.width > winWidth - 5) finalX = winWidth - rect.width - 5;
|
||||
if (finalY + rect.height > winHeight - 5) finalY = winHeight - rect.height - 5;
|
||||
|
||||
tooltipEl.style.transform = `translate(${finalX}px, ${finalY}px)`;
|
||||
// Reset top/left to 0 since we use transform
|
||||
tooltipEl.style.top = '0';
|
||||
tooltipEl.style.left = '0';
|
||||
};
|
||||
|
||||
document.body.addEventListener('mouseover', (e) => {
|
||||
const selector =
|
||||
'.card-title, .card-subtitle, .track-item-details .title, .track-item-details .artist, .now-playing-bar .title, .now-playing-bar .artist, .now-playing-bar .album, .pinned-item-name';
|
||||
const target = e.target.closest(selector);
|
||||
|
||||
if (target) {
|
||||
// Remove native title if present to avoid double tooltip
|
||||
if (target.hasAttribute('title')) {
|
||||
target.removeAttribute('title');
|
||||
}
|
||||
|
||||
if (target.scrollWidth > target.clientWidth) {
|
||||
tooltipEl.innerHTML = target.innerHTML.trim();
|
||||
tooltipEl.classList.add('visible');
|
||||
updateTooltipPosition(e);
|
||||
|
||||
const moveHandler = (moveEvent) => {
|
||||
updateTooltipPosition(moveEvent);
|
||||
};
|
||||
|
||||
const outHandler = () => {
|
||||
tooltipEl.classList.remove('visible');
|
||||
target.removeEventListener('mousemove', moveHandler);
|
||||
target.removeEventListener('mouseleave', outHandler);
|
||||
};
|
||||
|
||||
target.addEventListener('mousemove', moveHandler);
|
||||
target.addEventListener('mouseleave', outHandler);
|
||||
}
|
||||
const contextMenu = document.getElementById('context-menu');
|
||||
if (contextMenu && contextMenu.style.display === 'block' && !contextMenu.contains(e.target)) {
|
||||
contextMenu.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
64
js/ui.js
64
js/ui.js
|
|
@ -501,7 +501,7 @@ export class UIRenderer {
|
|||
});
|
||||
}
|
||||
|
||||
createUserPlaylistCardHTML(playlist) {
|
||||
createUserPlaylistCardHTML(playlist, customSubtitle = null) {
|
||||
let imageHTML = '';
|
||||
if (playlist.cover) {
|
||||
imageHTML = `<img src="${playlist.cover}" alt="${playlist.name}" class="card-image" loading="lazy">`;
|
||||
|
|
@ -538,6 +538,8 @@ export class UIRenderer {
|
|||
}
|
||||
|
||||
const isCompact = cardSettings.isCompactAlbum();
|
||||
const subtitle =
|
||||
customSubtitle || `${playlist.tracks ? playlist.tracks.length : playlist.numberOfTracks || 0} tracks`;
|
||||
|
||||
return this.createBaseCardHTML({
|
||||
type: 'user-playlist', // Note: data-type logic in base might need adjustment if it uses this for buttons.
|
||||
|
|
@ -545,7 +547,7 @@ export class UIRenderer {
|
|||
id: playlist.id,
|
||||
href: `/userplaylist/${playlist.id}`,
|
||||
title: escapeHtml(playlist.name),
|
||||
subtitle: `${playlist.tracks ? playlist.tracks.length : playlist.numberOfTracks || 0} tracks`,
|
||||
subtitle,
|
||||
imageHTML: imageHTML,
|
||||
actionButtonsHTML: `
|
||||
<button class="edit-playlist-btn" data-action="edit-playlist" title="Edit Playlist">
|
||||
|
|
@ -1791,6 +1793,26 @@ export class UIRenderer {
|
|||
itemsToStore.push({ el: null, data: track, type: 'track' });
|
||||
}
|
||||
}
|
||||
} else if (item.type === 'user-playlist') {
|
||||
if (item.id && item.name) {
|
||||
const playlist = {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
cover: item.cover,
|
||||
tracks: item.tracks || [],
|
||||
numberOfTracks: item.numberOfTracks || (item.tracks ? item.tracks.length : 0),
|
||||
};
|
||||
const subtitle = item.username ? `by ${item.username}` : null;
|
||||
cardsHTML.push(this.createUserPlaylistCardHTML(playlist, subtitle));
|
||||
itemsToStore.push({ el: null, data: playlist, type: 'user-playlist' });
|
||||
} else {
|
||||
const playlist = await syncManager.getPublicPlaylist(item.id);
|
||||
if (playlist) {
|
||||
const subtitle = item.username ? `by ${item.username}` : null;
|
||||
cardsHTML.push(this.createUserPlaylistCardHTML(playlist, subtitle));
|
||||
itemsToStore.push({ el: null, data: playlist, type: 'user-playlist' });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to load ${item.type} ${item.id}:`, e);
|
||||
|
|
@ -2413,7 +2435,7 @@ export class UIRenderer {
|
|||
}
|
||||
}
|
||||
|
||||
async loadRecommendedSongsForPlaylist(tracks) {
|
||||
async loadRecommendedSongsForPlaylist(tracks, forceRefresh = false) {
|
||||
const recommendedSection = document.getElementById('playlist-section-recommended');
|
||||
const recommendedContainer = document.getElementById('playlist-detail-recommended');
|
||||
|
||||
|
|
@ -2422,8 +2444,14 @@ export class UIRenderer {
|
|||
return;
|
||||
}
|
||||
|
||||
if (forceRefresh) {
|
||||
recommendedContainer.innerHTML = this.createSkeletonTracks(5, true);
|
||||
}
|
||||
|
||||
try {
|
||||
let recommendedTracks = await this.api.getRecommendedTracksForPlaylist(tracks, 20);
|
||||
let recommendedTracks = await this.api.getRecommendedTracksForPlaylist(tracks, 20, {
|
||||
refresh: forceRefresh,
|
||||
});
|
||||
|
||||
// Filter out blocked tracks
|
||||
const { contentBlockingSettings } = await import('./storage.js');
|
||||
|
|
@ -2689,6 +2717,18 @@ export class UIRenderer {
|
|||
// Load recommended songs thingy
|
||||
if (ownedPlaylist) {
|
||||
this.loadRecommendedSongsForPlaylist(tracks);
|
||||
|
||||
const refreshBtn = document.getElementById('refresh-recommended-songs-btn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.onclick = async () => {
|
||||
const icon = refreshBtn.querySelector('svg');
|
||||
if (icon) icon.style.animation = 'spin 1s linear infinite';
|
||||
refreshBtn.disabled = true;
|
||||
await this.loadRecommendedSongsForPlaylist(tracks, true);
|
||||
if (icon) icon.style.animation = '';
|
||||
refreshBtn.disabled = false;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Render Actions (Sort, Shuffle, Edit, Delete, Share)
|
||||
|
|
@ -3836,11 +3876,23 @@ export class UIRenderer {
|
|||
if (!instances || instances.length === 0) return '';
|
||||
|
||||
const listHtml = instances
|
||||
.map((url, index) => {
|
||||
.map((instance, index) => {
|
||||
const isObject = instance && typeof instance === 'object';
|
||||
const instanceUrl = isObject ? instance.url || '' : String(instance || '');
|
||||
const instanceName = isObject
|
||||
? instance.name || instance.displayName || instance.id || instanceUrl
|
||||
: instanceUrl;
|
||||
const instanceVersion = isObject && instance.version ? String(instance.version) : '';
|
||||
const safeName = escapeHtml(instanceName || 'Unknown instance');
|
||||
const safeUrl = escapeHtml(instanceUrl || '');
|
||||
const safeVersion = escapeHtml(instanceVersion);
|
||||
|
||||
return `
|
||||
<li data-index="${index}" data-type="${type}">
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div class="instance-url">${url}</div>
|
||||
<div class="instance-url">${safeName}</div>
|
||||
${safeUrl && safeUrl !== safeName ? `<div style="font-size: 0.8rem; color: var(--muted-foreground); margin-top: 0.15rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${safeUrl}</div>` : ''}
|
||||
${safeVersion ? `<div style="font-size: 0.75rem; color: var(--muted-foreground); margin-top: 0.1rem;">v${safeVersion}</div>` : ''}
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button class="move-up" title="Move Up" ${index === 0 ? 'disabled' : ''}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
[
|
||||
{
|
||||
"type": "user-playlist",
|
||||
"id": "e64ed040-57ab-4583-b047-8fb590b04750",
|
||||
"name": "2edi",
|
||||
"cover": "https://i.imgur.gg/jgYh3K6-favicon_(2).png",
|
||||
"username": "edideaur"
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 89313048,
|
||||
|
|
@ -12,9 +19,20 @@
|
|||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 118353565,
|
||||
"id": 324660713,
|
||||
"title": "JOECHILLWORLD",
|
||||
"artist": { "id": 3972883, "name": "Devon Hendryx" },
|
||||
"releaseDate": "2010-08-10",
|
||||
"cover": "25d45544-3e82-4184-b8c2-2c2c6f0f152a",
|
||||
"explicit": true,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"mediaMetadata": { "tags": ["LOSSLESS", "HIRES_LOSSLESS"] }
|
||||
},
|
||||
{
|
||||
"type": "album",
|
||||
"id": 418729278,
|
||||
"title": "I LAY DOWN MY LIFE FOR YOU: DIRECTOR'S CUT",
|
||||
"artist": { "id": 439890147, "name": "JPEGMAFIA" },
|
||||
"artist": { "id": 7958797, "name": "JPEGMAFIA" },
|
||||
"releaseDate": "2025-02-03",
|
||||
"cover": "9c84302b-2584-4c0a-9db7-e648542f459f",
|
||||
"explicit": true,
|
||||
|
|
|
|||
42
styles.css
42
styles.css
|
|
@ -1627,6 +1627,17 @@ input[type='search']::-webkit-search-cancel-button {
|
|||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.section-header-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.section-header-row .section-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.search-tabs {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
|
|
@ -1987,7 +1998,7 @@ input[type='search']::-webkit-search-cancel-button {
|
|||
}
|
||||
|
||||
#playlist-detail-recommended .track-item {
|
||||
grid-template-columns: 40px 1fr 32px 64px;
|
||||
grid-template-columns: 40px 1fr 32px auto;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
|
|
@ -2079,7 +2090,7 @@ input[type='search']::-webkit-search-cancel-button {
|
|||
/* Track Item Standardization */
|
||||
.track-item {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 60px 40px;
|
||||
grid-template-columns: 40px 1fr 60px auto;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-sm);
|
||||
|
|
@ -2163,7 +2174,6 @@ input[type='search']::-webkit-search-cancel-button {
|
|||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
|
@ -2240,12 +2250,6 @@ input[type='search']::-webkit-search-cancel-button {
|
|||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Editable Playlist Track Items (with remove button) */
|
||||
.is-editable .track-list-header,
|
||||
.is-editable .track-item {
|
||||
grid-template-columns: 40px 1fr 80px 90px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
@ -4598,7 +4602,7 @@ input:checked + .slider::before {
|
|||
|
||||
#playlist-detail-tracklist .track-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 80px 48px;
|
||||
grid-template-columns: 40px 1fr 80px auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm);
|
||||
|
|
@ -5469,6 +5473,18 @@ img[src=''] {
|
|||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
html,
|
||||
body {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
body {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.player-controls .progress-container {
|
||||
order: -1;
|
||||
max-width: none;
|
||||
|
|
@ -5504,13 +5520,15 @@ img[src=''] {
|
|||
'header' auto
|
||||
'main' 1fr
|
||||
'player' auto / 1fr;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: var(--spacing-md);
|
||||
grid-area: main;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
|
|
|
|||
Loading…
Reference in a new issue