diff --git a/js/app.js b/js/app.js index 35de81c..dfafd45 100644 --- a/js/app.js +++ b/js/app.js @@ -8,7 +8,7 @@ import { LastFMScrobbler } from './lastfm.js'; import { LyricsManager, createLyricsPanel, showKaraokeView, showSyncedLyricsPanel, clearLyricsPanelSync } from './lyrics.js'; import { createRouter, updateTabTitle } from './router.js'; import { initializeSettings } from './settings.js'; -import { initializePlayerEvents, initializeTrackInteractions } from './events.js'; +import { initializePlayerEvents, initializeTrackInteractions, handleTrackAction } from './events.js'; import { initializeUIInteractions } from './ui-interactions.js'; import { downloadAlbumAsZip, downloadDiscography, downloadPlaylistAsZip } from './downloads.js'; import { debounce, SVG_PLAY } from './utils.js'; @@ -275,7 +275,7 @@ document.addEventListener('DOMContentLoaded', async () => { document.getElementById('download-current-btn')?.addEventListener('click', () => { if (player.currentTrack) { - downloadTrackWithMetadata(player.currentTrack, player.quality, api, lyricsManager); + handleTrackAction('download', player.currentTrack, player, api, lyricsManager); } }); diff --git a/js/downloads.js b/js/downloads.js index 7df322d..10ac3d4 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -4,6 +4,39 @@ import { lyricsSettings } from './storage.js'; const downloadTasks = new Map(); let downloadNotificationContainer = null; +const coverCache = new Map(); + +/** + * Fetches and caches cover art as a Blob + */ +async function getCoverBlob(api, coverId) { + if (!coverId) return null; + if (coverCache.has(coverId)) return coverCache.get(coverId); + + try { + const url = api.getCoverUrl(coverId, '1280'); + const response = await fetch(url); + if (response.ok) { + const blob = await response.blob(); + coverCache.set(coverId, blob); + return blob; + } + } catch (error) { + console.warn('Cover fetch failed:', error); + } + return null; +} + +/** + * Adds a cover blob to a JSZip instance + */ +function addCoverBlobToZip(zip, folderPath, blob) { + if (!blob) return; + const path = folderPath ? `${folderPath}/cover.jpg` : 'cover.jpg'; + if (!zip.file(path)) { + zip.file(path, blob); + } +} async function loadJSZip() { try { @@ -148,7 +181,7 @@ function removeDownloadTask(trackId) { taskEl.remove(); downloadTasks.delete(trackId); - if (downloadNotificationContainer && downloadNotificationContainer.children.length === 0) { + if (downloadNotificationContainer && downloadNotificationContainer.children.length === 0) { downloadNotificationContainer.remove(); downloadNotificationContainer = null; } @@ -177,55 +210,16 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null) { return blob; } -function buildTrackMetadata(track, api) { - const artists = []; - if (Array.isArray(track.artists) && track.artists.length) { - for (const a of track.artists) artists.push(a.name || a); - } else if (track.artist && track.artist.name) { - artists.push(track.artist.name); - } - - return { - id: track.id, - title: track.title || null, - artists, - album: track.album?.title || null, - albumArtist: track.album?.artist?.name || track.artist?.name || null, - trackNumber: track.trackNumber ?? null, - discNumber: track.discNumber ?? null, - durationMs: track.duration ?? null, - releaseDate: track.album?.releaseDate || null, - bitrate: track.audioQuality || null - }; -} - -async function addCoverToZipIfMissing(zip, folderPath, coverId, api) { - if (!coverId) return; - - const coverPath = folderPath ? `${folderPath}/cover.jpg` : 'cover.jpg'; - if (zip.file(coverPath)) return; - - try { - const url = api.getCoverUrl(coverId, '1000'); - const resp = await fetch(url); - if (!resp.ok) return; - const blob = await resp.blob(); - zip.file(coverPath, blob); - } catch (e) { - - console.warn('Could not fetch cover for zip:', e); - } -} - export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsManager = null) { const JSZip = await loadJSZip(); const zip = new JSZip(); - const template = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist} - monochrome.tf'; - const releaseDate = album.releaseDate ? new Date(album.releaseDate) : null; + const coverBlob = await getCoverBlob(api, album.cover || album.album?.cover || album.coverId); + const releaseDateStr = album.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : ''); + const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null; const year = (releaseDate && !isNaN(releaseDate.getTime())) ? releaseDate.getFullYear() : ''; - const folderName = formatTemplate(template, { + const folderName = formatTemplate(localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}', { albumTitle: album.title, albumArtist: album.artist?.name, year: year @@ -234,8 +228,7 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana const notification = createBulkDownloadNotification('album', album.title, tracks.length); try { - - const albumCoverId = album.cover || album.album?.cover || album.coverId || null; + addCoverBlobToZip(zip, folderName, coverBlob); for (let i = 0; i < tracks.length; i++) { const track = tracks[i]; @@ -281,7 +274,7 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana } } - updateBulkDownloadProgress(notification, tracks.length, tracks.length, 'Creating ZIP...'); + updateBulkDownloadProgress(notification, tracks.length, tracks.length, 'Creating ZIP...'); const zipBlob = await zip.generateAsync({ type: 'blob', @@ -305,18 +298,17 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana } } -export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyricsManager = null) { +export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyricsManager = null) { const JSZip = await loadJSZip(); const zip = new JSZip(); - const template = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist} - monochrome.tf'; - const folderName = formatTemplate(template, { + const folderName = formatTemplate(localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}', { albumTitle: playlist.title, albumArtist: 'Playlist', year: new Date().getFullYear() }); - const notification = createBulkDownloadNotification('playlist', playlist.title, tracks.length); + const notification = createBulkDownloadNotification('playlist', playlist.title, tracks.length); try { for (let i = 0; i < tracks.length; i++) { @@ -327,10 +319,11 @@ export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyri updateBulkDownloadProgress(notification, i, tracks.length, trackTitle); try { + const coverBlob = await getCoverBlob(api, track.album?.cover); const blob = await downloadTrackBlob(track, quality, api); zip.file(`${folderName}/${filename}`, blob); - - // add metadata JSON + + addCoverBlobToZip(zip, folderName, coverBlob); try { const meta = buildTrackMetadata(track, api); const metaFilename = filename.replace(/\.[^.]+$/, '.json'); @@ -363,7 +356,7 @@ export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyri } } - updateBulkDownloadProgress(notification, tracks.length, tracks.length, 'Creating ZIP...'); + updateBulkDownloadProgress(notification, tracks.length, tracks.length, 'Creating ZIP...'); const zipBlob = await zip.generateAsync({ type: 'blob', @@ -391,8 +384,7 @@ export async function downloadDiscography(artist, api, quality, lyricsManager = const JSZip = await loadJSZip(); const zip = new JSZip(); - const template = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist} - monochrome.tf'; - const rootFolder = `${sanitizeForFilename(artist.name)} discography - monochrome.tf`; + const rootFolder = `${sanitizeForFilename(artist.name)} discography`; const allReleases = [...(artist.albums || []), ...(artist.eps || [])]; const totalAlbums = allReleases.length; @@ -406,7 +398,10 @@ export async function downloadDiscography(artist, api, quality, lyricsManager = try { const { album: fullAlbum, tracks } = await api.getAlbum(album.id); - const releaseDate = fullAlbum.releaseDate ? new Date(fullAlbum.releaseDate) : null; + const coverBlob = await getCoverBlob(api, fullAlbum.cover || album.cover); + + const releaseDateStr = fullAlbum.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : ''); + const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null; const year = (releaseDate && !isNaN(releaseDate.getTime())) ? releaseDate.getFullYear() : ''; const albumFolder = formatTemplate(template, { @@ -415,6 +410,8 @@ export async function downloadDiscography(artist, api, quality, lyricsManager = year: year }); + addCoverBlobToZip(zip, `${rootFolder}/${albumFolder}`, coverBlob); + for (const track of tracks) { const filename = buildTrackFilename(track, quality); @@ -422,13 +419,7 @@ export async function downloadDiscography(artist, api, quality, lyricsManager = const blob = await downloadTrackBlob(track, quality, api); zip.file(`${rootFolder}/${albumFolder}/${filename}`, blob); - try { - const meta = buildTrackMetadata(track, api); - const metaFilename = filename.replace(/\.[^.]+$/, '.json'); - zip.file(`${rootFolder}/${albumFolder}/${metaFilename}`, JSON.stringify(meta, null, 2)); - } catch (e) { - console.warn('Could not attach metadata for', track.title, e); - } + try { await addCoverToZipIfMissing(zip, `${rootFolder}/${albumFolder}`, track.album?.cover || album.cover, api); @@ -487,7 +478,7 @@ function createBulkDownloadNotification(type, name, totalItems) { const notifEl = document.createElement('div'); notifEl.className = 'download-task bulk-download'; - const typeLabel = type === 'album' ? 'Album' : type === 'playlist' ? 'Playlist' : 'Discography'; + const typeLabel = type === 'album' ? 'Album' : type === 'playlist' ? 'Playlist' : 'Discography'; notifEl.innerHTML = `
@@ -562,102 +553,25 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag api ); - // Manually fetch the stream so we can include metadata and cover in a ZIP - const lookup = await api.getTrack(track.id, quality); - let streamUrl; - - if (lookup.originalTrackUrl) { - streamUrl = lookup.originalTrackUrl; - } else { - streamUrl = api.extractStreamUrlFromManifest(lookup.info.manifest); - if (!streamUrl) { - throw new Error('Could not resolve stream URL'); + await api.downloadTrack(track.id, quality, filename, { + signal: controller.signal, + onProgress: (progress) => { + updateDownloadProgress(track.id, progress); } - } + }); - const resp = await fetch(streamUrl, { signal: controller.signal, cache: 'no-store' }); - if (!resp.ok) throw new Error(`Fetch failed: ${resp.status}`); - - const contentLength = resp.headers.get('Content-Length'); - const totalBytes = contentLength ? parseInt(contentLength, 10) : 0; - let receivedBytes = 0; - - const reader = resp.body ? resp.body.getReader() : null; - const chunks = []; - - if (reader) { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (value) { - chunks.push(value); - receivedBytes += value.byteLength; - updateDownloadProgress(track.id, { - stage: 'downloading', - receivedBytes, - totalBytes: totalBytes || undefined - }); - } - } - } else { - const blob = await resp.blob(); - chunks.push(new Uint8Array(await blob.arrayBuffer())); - receivedBytes = chunks.reduce((s, c) => s + c.length, 0); - updateDownloadProgress(track.id, { stage: 'downloading', receivedBytes, totalBytes: receivedBytes }); - } - - const audioBlob = new Blob(chunks, { type: resp.headers.get('Content-Type') || 'audio/flac' }); - - // Create ZIP with audio + metadata + cover + lyrics - const JSZip = await loadJSZip(); - const zip = new JSZip(); - - zip.file(filename, audioBlob); - - try { - const meta = buildTrackMetadata(track, api); - const metaFilename = filename.replace(/\.[^.]+$/, '.json'); - const jsonContent = JSON.stringify(meta, null, 2); - zip.file(metaFilename, jsonContent); - } catch (e) { - console.warn('Could not create metadata for current track', e); - } - - try { - await addCoverToZipIfMissing(zip, '', track.album?.cover, api); - } catch (e) {} + completeDownloadTask(track.id, true); if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { try { const lyricsData = await lyricsManager.fetchLyrics(track.id); if (lyricsData) { - const lrcContent = lyricsManager.generateLRCContent(lyricsData, track); - if (lrcContent) { - const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc'); - zip.file(lrcFilename, lrcContent); - } + lyricsManager.downloadLRC(lyricsData, track); } } catch (error) { console.log('Could not download lyrics for track'); } } - - updateDownloadProgress(track.id, { stage: 'downloading', receivedBytes: receivedBytes, totalBytes }); - - const zipBlob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } }, (metadata) => { - // metadata.percent available but we already show streaming progress - }); - - const url = URL.createObjectURL(zipBlob); - const a = document.createElement('a'); - a.href = url; - a.download = filename.replace(/\.[^.]+$/, '') + '.zip'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - completeDownloadTask(track.id, true); } catch (error) { if (error.name !== 'AbortError') { const errorMsg = error.message === RATE_LIMIT_ERROR_MESSAGE @@ -666,4 +580,4 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag completeDownloadTask(track.id, false, errorMsg); } } -} +} \ No newline at end of file diff --git a/js/events.js b/js/events.js index 7111668..102f4c0 100644 --- a/js/events.js +++ b/js/events.js @@ -1,7 +1,7 @@ //js/events.js -import { SVG_PLAY, SVG_PAUSE, SVG_VOLUME, SVG_MUTE, REPEAT_MODE, trackDataStore, RATE_LIMIT_ERROR_MESSAGE, buildTrackFilename } from './utils.js'; +import { SVG_PLAY, SVG_PAUSE, SVG_VOLUME, SVG_MUTE, REPEAT_MODE, trackDataStore } from './utils.js'; import { lastFMStorage } from './storage.js'; -import { addDownloadTask, updateDownloadProgress, completeDownloadTask, showNotification, downloadTrackWithMetadata } from './downloads.js'; +import { showNotification, downloadTrackWithMetadata } from './downloads.js'; import { lyricsSettings } from './storage.js'; import { updateTabTitle } from './router.js'; @@ -290,6 +290,22 @@ function initializeSmoothSliders(audioPlayer, player) { }); } +export async function handleTrackAction(action, track, player, api, lyricsManager) { + if (!track) return; + + if (action === 'add-to-queue') { + player.addToQueue(track); + renderQueue(player); + showNotification(`Added to queue: ${track.title}`); + } else if (action === 'play-next') { + player.addNextToQueue(track); + renderQueue(player); + showNotification(`Playing next: ${track.title}`); + } else if (action === 'download') { + await downloadTrackWithMetadata(track, player.quality, api, lyricsManager); + } +} + export function initializeTrackInteractions(player, api, mainContent, contextMenu, lyricsManager) { let contextTrack = null; @@ -300,19 +316,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen const trackItem = actionBtn.closest('.track-item'); if (trackItem) { const track = trackDataStore.get(trackItem); - const action = actionBtn.dataset.action; - - if (action === 'add-to-queue' && track) { - player.addToQueue(track); - renderQueue(player); - showNotification(`Added to queue: ${track.title}`); - } else if (action === 'play-next' && track) { - player.addNextToQueue(track); - renderQueue(player); - showNotification(`Playing next: ${track.title}`); - } else if (action === 'download' && track) { - handleDownload(track, player, api); - } + handleTrackAction(actionBtn.dataset.action, track, player, api, lyricsManager); } return; } @@ -367,19 +371,9 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen contextMenu.addEventListener('click', async e => { e.stopPropagation(); const action = e.target.dataset.action; - - if (action === 'play-next' && contextTrack) { - player.addNextToQueue(contextTrack); - renderQueue(player); - showNotification(`Playing next: ${contextTrack.title}`); - } else if (action === 'add-to-queue' && contextTrack) { - player.addToQueue(contextTrack); - renderQueue(player); - showNotification(`Added to queue: ${contextTrack.title}`); - } else if (action === 'download' && contextTrack) { - await downloadTrackWithMetadata(contextTrack, player.quality, api, lyricsManager); + if (action && contextTrack) { + await handleTrackAction(action, contextTrack, player, api, lyricsManager); } - contextMenu.style.display = 'none'; }); @@ -451,33 +445,3 @@ function positionMenu(menu, x, y, anchorRect = null) { menu.style.left = `${left}px`; menu.style.visibility = 'visible'; } - -async function handleDownload(track, player, api) { - const quality = player.quality; - const filename = buildTrackFilename(track, quality); - - try { - const { taskEl, abortController } = addDownloadTask( - track.id, - track, - filename, - api - ); - - await api.downloadTrack(track.id, quality, filename, { - signal: abortController.signal, - onProgress: (progress) => { - updateDownloadProgress(track.id, progress); - } - }); - - completeDownloadTask(track.id, true); - } catch (error) { - if (error.name !== 'AbortError') { - const errorMsg = error.message === RATE_LIMIT_ERROR_MESSAGE - ? error.message - : 'Download failed. Please try again.'; - completeDownloadTask(track.id, false, errorMsg); - } - } -} diff --git a/js/settings.js b/js/settings.js index 6c3aa96..6755f9a 100644 --- a/js/settings.js +++ b/js/settings.js @@ -215,7 +215,7 @@ export function initializeSettings(scrobbler, player, api, ui) { // ZIP folder template const zipFolderTemplate = document.getElementById('zip-folder-template'); if (zipFolderTemplate) { - zipFolderTemplate.value = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist} - monochrome.tf'; + zipFolderTemplate.value = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}'; zipFolderTemplate.addEventListener('change', (e) => { localStorage.setItem('zip-folder-template', e.target.value); }); diff --git a/js/ui.js b/js/ui.js index 3b78783..5b8cf94 100644 --- a/js/ui.js +++ b/js/ui.js @@ -836,5 +836,4 @@ export class UIRenderer { } }); } - }