From 1f13e342496beabb97b7c01545c63334bc11c43a Mon Sep 17 00:00:00 2001 From: Sietse <144368086+Sietse2202@users.noreply.github.com> Date: Sat, 21 Feb 2026 20:53:42 +0100 Subject: [PATCH 01/19] fix(player): Uniform shuffle Replaces the current naive solution with Fisher-Yates --- js/player.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/js/player.js b/js/player.js index 1a4689b..2013471 100644 --- a/js/player.js +++ b/js/player.js @@ -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]; From 87a8368fc3d5d17283a8680313d552e49d2dd6a7 Mon Sep 17 00:00:00 2001 From: SamidyFR <168582143+SamidyFR@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:58:57 +0000 Subject: [PATCH 02/19] style: auto-fix linting issues --- js/player.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/player.js b/js/player.js index 2013471..7fdcab5 100644 --- a/js/player.js +++ b/js/player.js @@ -682,8 +682,8 @@ export class Player { } 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]]; + const j = Math.floor(Math.random() * (i + 1)); + [tracksToShuffle[i], tracksToShuffle[j]] = [tracksToShuffle[j], tracksToShuffle[i]]; } if (currentTrack) { From 73b9867d4bab47530d81aa2bff9bd729ce752cfc Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Sat, 21 Feb 2026 23:33:30 +0100 Subject: [PATCH 03/19] FIX: tooltip and css issues --- js/ui-interactions.js | 9 +++++++++ styles.css | 13 +++---------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/js/ui-interactions.js b/js/ui-interactions.js index b795529..3703d39 100644 --- a/js/ui-interactions.js +++ b/js/ui-interactions.js @@ -537,11 +537,20 @@ export function initializeUIInteractions(player, api, ui) { 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); } } }); + + // Hide tooltip on any click to be safe + document.addEventListener('mousedown', () => { + if (tooltipEl) { + tooltipEl.classList.remove('visible'); + } + }); } diff --git a/styles.css b/styles.css index 7f2b73a..185a5da 100644 --- a/styles.css +++ b/styles.css @@ -1987,7 +1987,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 +2079,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 +2163,6 @@ input[type='search']::-webkit-search-cancel-button { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - display: flex; align-items: center; } @@ -2240,12 +2239,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; @@ -4568,7 +4561,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); From 888703f18b5cf5fdc510357a47f71acc4883d069 Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Sat, 21 Feb 2026 23:40:04 +0100 Subject: [PATCH 04/19] fix: resolve persistent labels --- js/ui-interactions.js | 158 ++++++++++++++++++++++-------------------- 1 file changed, 84 insertions(+), 74 deletions(-) diff --git a/js/ui-interactions.js b/js/ui-interactions.js index 3703d39..6b7e08c 100644 --- a/js/ui-interactions.js +++ b/js/ui-interactions.js @@ -473,84 +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; - } - - 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); - } - } - }); - - // Hide tooltip on any click to be safe - document.addEventListener('mousedown', () => { + // Hide tooltip and context menu on any click to be safe + document.addEventListener('mousedown', (e) => { if (tooltipEl) { tooltipEl.classList.remove('visible'); } + + const contextMenu = document.getElementById('context-menu'); + if (contextMenu && contextMenu.style.display === 'block' && !contextMenu.contains(e.target)) { + contextMenu.style.display = 'none'; + } }); } From 91ecca534bd13fce56974c2b0f0b8bbd147277cd Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Sat, 21 Feb 2026 23:50:59 +0100 Subject: [PATCH 05/19] Fix mobile scroll container to restore browser URL bar collapse --- styles.css | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/styles.css b/styles.css index 185a5da..f63f59d 100644 --- a/styles.css +++ b/styles.css @@ -5432,6 +5432,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; @@ -5467,13 +5479,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 { From 400197aabcda6ec67d3c56bbe752b974b9841af9 Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Sat, 21 Feb 2026 23:52:33 +0100 Subject: [PATCH 06/19] Write disc number metadata for FLAC and M4A downloads --- js/metadata.js | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/js/metadata.js b/js/metadata.js index ee72a2a..1db2323 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -544,6 +544,7 @@ function parseFlacBlocks(dataView) { function createVorbisCommentBlock(track) { // Vorbis comment structure const comments = []; + const discNumber = track.volumeNumber ?? track.discNumber; // Add standard tags if (track.title) { @@ -562,6 +563,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)]); } @@ -920,7 +924,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 +1070,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 +1205,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 +1229,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; From bf346f756e90041c9d330f8e94bb662781947cea Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Sun, 22 Feb 2026 00:32:45 +0100 Subject: [PATCH 07/19] Add multi-disc ZIP folders and fix playlist extension paths --- index.html | 12 ++ js/downloads.js | 258 ++++++++++++++++++++++++++++++++++++--- js/playlist-generator.js | 22 ++-- js/settings.js | 8 ++ js/storage.js | 14 +++ 5 files changed, 287 insertions(+), 27 deletions(-) diff --git a/index.html b/index.html index 48c0ec1..9a8c9aa 100644 --- a/index.html +++ b/index.html @@ -4501,6 +4501,18 @@ +
+
+ Separate Discs in ZIP + Put tracks in Disc folders when a release has multiple discs +
+ +
diff --git a/js/downloads.js b/js/downloads.js index 559fb72..11f073f 100644 --- a/js/downloads.js +++ b/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,21 @@ 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 +1066,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 +1108,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 +1129,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 +1298,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); diff --git a/js/playlist-generator.js b/js/playlist-generator.js index 731a193..3b22b21 100644 --- a/js/playlist-generator.js +++ b/js/playlist-generator.js @@ -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}`; } /** diff --git a/js/settings.js b/js/settings.js index 8dbbdf2..a69abfa 100644 --- a/js/settings.js +++ b/js/settings.js @@ -2533,6 +2533,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'); diff --git a/js/storage.js b/js/storage.js index 536133c..e590063 100644 --- a/js/storage.js +++ b/js/storage.js @@ -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 = { From 651f4282e597e186204e9510cdc0e0b3e6295d51 Mon Sep 17 00:00:00 2001 From: JulienMaille <182520+JulienMaille@users.noreply.github.com> Date: Sat, 21 Feb 2026 23:33:29 +0000 Subject: [PATCH 08/19] style: auto-fix linting issues --- js/downloads.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/js/downloads.js b/js/downloads.js index 11f073f..49908b5 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -1047,7 +1047,8 @@ export async function downloadDiscography(artist, selectedReleases, api, quality const discLayout = await createDiscLayoutContext(tracks, api); const separateByDisc = discLayout.separateByDisc; const playlistPathResolver = separateByDisc - ? (_track, filename, index) => `${getDiscFolderName(discLayout.resolveDiscNumber(index))}/${filename}` + ? (_track, filename, index) => + `${getDiscFolderName(discLayout.resolveDiscNumber(index))}/${filename}` : null; if (playlistSettings.shouldGenerateM3U()) { From c1973e81ffba66c4bb71911408b3f0a59c59c2d6 Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Sun, 22 Feb 2026 00:46:05 +0100 Subject: [PATCH 09/19] Shuffle full artist discography instead of artist radio --- js/app.js | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/js/app.js b/js/app.js index adf8779..0374553 100644 --- a/js/app.js +++ b/js/app.js @@ -837,7 +837,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 = + 'Shuffling...'; + + 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'); From 747f50f56436e1249a105d8d7fecc4f1efa83d26 Mon Sep 17 00:00:00 2001 From: GooglyBlox <53668973+GooglyBlox@users.noreply.github.com> Date: Sat, 21 Feb 2026 17:15:28 -0800 Subject: [PATCH 10/19] feat: add all artists to metadata --- js/metadata.js | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/js/metadata.js b/js/metadata.js index 1db2323..f2d5785 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -5,6 +5,33 @@ 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 @@ -550,8 +577,9 @@ function createVorbisCommentBlock(track) { 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]); @@ -910,7 +938,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, }; From 6b55fd4c7a8df49fb585e96761dcf5ac0b20f7b1 Mon Sep 17 00:00:00 2001 From: EduardPrigoanaAlt <196915955+EduardPrigoanaAlt@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:47:05 +0000 Subject: [PATCH 11/19] style: auto-fix linting issues --- js/metadata.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/js/metadata.js b/js/metadata.js index f2d5785..6cba6bb 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -10,15 +10,22 @@ const DEFAULT_ALBUM = 'Unknown Album'; * 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] : []; + 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)); + 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) { From e941d3ebfaf497fd6fb84f86fcdebb262adc7966 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Sun, 22 Feb 2026 18:21:55 +0200 Subject: [PATCH 12/19] change max size to 100mb as thats the maximum allowed by CF workers --- functions/upload.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/functions/upload.js b/functions/upload.js index e784fe1..8d5cea4 100644 --- a/functions/upload.js +++ b/functions/upload.js @@ -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(); From ac8957ada3a2f8b9b80391670c492c040f6430da Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Sun, 22 Feb 2026 19:27:28 +0200 Subject: [PATCH 13/19] Update README.md --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index aa3f2ca..2f6b085 100644 --- a/README.md +++ b/README.md @@ -218,3 +218,13 @@ We welcome contributions from the community! Please see our [Contributing Guide]

Made with ❤️ by the Monochrome team

+ +## Star History + + + + + + Star History Chart + + From 7c4e1ec6e71960e3b2d8d0486a8154222a02eca1 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Sun, 22 Feb 2026 20:51:48 +0200 Subject: [PATCH 14/19] Update editors-picks.json --- public/editors-picks.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/editors-picks.json b/public/editors-picks.json index a63ad62..3225279 100644 --- a/public/editors-picks.json +++ b/public/editors-picks.json @@ -12,9 +12,9 @@ }, { "type": "album", - "id": 118353565, + "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, From 1aaf2dfd4665b14336844800f0caf0d0965d934e Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Sun, 22 Feb 2026 21:26:59 +0100 Subject: [PATCH 15/19] Fix API instances settings list rendering for object entries --- js/ui.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/js/ui.js b/js/ui.js index 38c75ce..c159202 100644 --- a/js/ui.js +++ b/js/ui.js @@ -3785,11 +3785,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 `
  • -
    ${url}
    +
    ${safeName}
    + ${safeUrl && safeUrl !== safeName ? `
    ${safeUrl}
    ` : ''} + ${safeVersion ? `
    v${safeVersion}
    ` : ''}
    +
    diff --git a/js/ui.js b/js/ui.js index c159202..6160b1d 100644 --- a/js/ui.js +++ b/js/ui.js @@ -492,7 +492,7 @@ export class UIRenderer { }); } - createUserPlaylistCardHTML(playlist) { + createUserPlaylistCardHTML(playlist, customSubtitle = null) { let imageHTML = ''; if (playlist.cover) { imageHTML = `${playlist.name}`; @@ -529,6 +529,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. @@ -536,7 +538,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: `