From e0cfaba14c8103f230f8f41b3cc8c4a62dbb9f7d Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Fri, 26 Dec 2025 00:42:50 +0100 Subject: [PATCH 1/8] wip: cleanup broken metadata handling --- js/app.js | 4 +- js/downloads.js | 214 +++++++++++++++--------------------------------- js/events.js | 78 +++++------------- js/settings.js | 2 +- js/ui.js | 1 - 5 files changed, 88 insertions(+), 211 deletions(-) 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 { } }); } - } From 64e4c0f43c9196d07063266f4be2f5b9dd4c1e32 Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Fri, 26 Dec 2025 00:47:11 +0100 Subject: [PATCH 2/8] Fix CORS issue when fetching cover art for downloads --- js/downloads.js | 29 +++++++++++++++++++++++++++-- sw.js | 3 +++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/js/downloads.js b/js/downloads.js index 10ac3d4..25c5eb1 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -13,16 +13,41 @@ async function getCoverBlob(api, coverId) { if (!coverId) return null; if (coverCache.has(coverId)) return coverCache.get(coverId); + const fetchWithProxy = async (url) => { + try { + const proxyUrl = `https://corsproxy.io/?${encodeURIComponent(url)}`; + const response = await fetch(proxyUrl); + if (response.ok) return await response.blob(); + } catch (e) { + console.warn('Proxy fetch failed:', e); + } + return null; + }; + try { const url = api.getCoverUrl(coverId, '1280'); + // Try direct fetch first const response = await fetch(url); if (response.ok) { const blob = await response.blob(); coverCache.set(coverId, blob); return blob; + } else { + // If direct fetch fails (e.g. 404 from SW due to CORS), try proxy + const blob = await fetchWithProxy(url); + if (blob) { + coverCache.set(coverId, blob); + return blob; + } + } + } catch (e) { + // Network error (CORS rejection not handled by SW), try proxy + const url = api.getCoverUrl(coverId, '1280'); + const blob = await fetchWithProxy(url); + if (blob) { + coverCache.set(coverId, blob); + return blob; } - } catch (error) { - console.warn('Cover fetch failed:', error); } return null; } diff --git a/sw.js b/sw.js index f4882e1..a6fc714 100644 --- a/sw.js +++ b/sw.js @@ -45,6 +45,9 @@ self.addEventListener('fetch', event => { cache.put(event.request, networkResponse.clone()); } return networkResponse; + }).catch(() => { + // If fetch fails (e.g. CORS), return null/error so client handles it + return new Response(null, { status: 404, statusText: 'Image fetch failed' }); }); }); }) From 63854f4d69ffd6fabaec895e2a3cbbda73b60f91 Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Fri, 26 Dec 2025 01:03:15 +0100 Subject: [PATCH 3/8] wip add metadata --- js/api.js | 23 ++- js/downloads.js | 42 +--- js/metadata.js | 531 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 557 insertions(+), 39 deletions(-) create mode 100644 js/metadata.js diff --git a/js/api.js b/js/api.js index 4ba9fa3..234d2b5 100644 --- a/js/api.js +++ b/js/api.js @@ -1,6 +1,7 @@ //js/api.js import { RATE_LIMIT_ERROR_MESSAGE, deriveTrackQuality, delay } from './utils.js'; import { APICache } from './cache.js'; +import { addMetadataToAudio } from './metadata.js'; export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE'; @@ -585,7 +586,7 @@ export class LosslessAPI { } async downloadTrack(id, quality = 'LOSSLESS', filename, options = {}) { - const { onProgress } = options; + const { onProgress, track } = options; try { const lookup = await this.getTrack(id, quality); @@ -613,6 +614,7 @@ export class LosslessAPI { const totalBytes = contentLength ? parseInt(contentLength, 10) : 0; let receivedBytes = 0; + let blob; if (response.body && onProgress) { const reader = response.body.getReader(); @@ -634,10 +636,9 @@ export class LosslessAPI { } } - const blob = new Blob(chunks, { type: response.headers.get('Content-Type') || 'audio/flac' }); - this.triggerDownload(blob, filename); + blob = new Blob(chunks, { type: response.headers.get('Content-Type') || 'audio/flac' }); } else { - const blob = await response.blob(); + blob = await response.blob(); if (onProgress) { onProgress({ stage: 'downloading', @@ -645,8 +646,20 @@ export class LosslessAPI { totalBytes: blob.size }); } - this.triggerDownload(blob, filename); } + + // Add metadata if track information is provided + if (track) { + if (onProgress) { + onProgress({ + stage: 'processing', + message: 'Adding metadata...' + }); + } + blob = await addMetadataToAudio(blob, track, this, quality); + } + + this.triggerDownload(blob, filename); } catch (error) { if (error.name === 'AbortError') { throw error; diff --git a/js/downloads.js b/js/downloads.js index 25c5eb1..90c186c 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -1,6 +1,7 @@ //js/downloads.js import { buildTrackFilename, sanitizeForFilename, RATE_LIMIT_ERROR_MESSAGE, getTrackArtists, getTrackTitle, formatTemplate, SVG_CLOSE } from './utils.js'; import { lyricsSettings } from './storage.js'; +import { addMetadataToAudio } from './metadata.js'; const downloadTasks = new Map(); let downloadNotificationContainer = null; @@ -230,8 +231,12 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null) { if (!response.ok) { throw new Error(`Failed to fetch track: ${response.status}`); } - - const blob = await response.blob(); + + let blob = await response.blob(); + + // Add metadata to the blob + blob = await addMetadataToAudio(blob, track, api, quality); + return blob; } @@ -265,20 +270,6 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana try { const blob = await downloadTrackBlob(track, quality, api); zip.file(`${folderName}/${filename}`, blob); - - try { - const meta = buildTrackMetadata(track, api); - const metaFilename = filename.replace(/\.[^.]+$/, '.json'); - zip.file(`${folderName}/${metaFilename}`, JSON.stringify(meta, null, 2)); - } catch (e) { - console.warn('Could not attach metadata for', trackTitle, e); - } - - try { - await addCoverToZipIfMissing(zip, folderName, albumCoverId || track.album?.cover, api); - } catch (e) { - - } if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { try { @@ -349,18 +340,6 @@ export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyri zip.file(`${folderName}/${filename}`, blob); addCoverBlobToZip(zip, folderName, coverBlob); - try { - const meta = buildTrackMetadata(track, api); - const metaFilename = filename.replace(/\.[^.]+$/, '.json'); - zip.file(`${folderName}/${metaFilename}`, JSON.stringify(meta, null, 2)); - } catch (e) { - console.warn('Could not attach metadata for', trackTitle, e); - } - - // add cover per track/playlist (attempt once per track) - try { - await addCoverToZipIfMissing(zip, folderName, track.album?.cover, api); - } catch (e) {} if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { try { @@ -444,12 +423,6 @@ export async function downloadDiscography(artist, api, quality, lyricsManager = const blob = await downloadTrackBlob(track, quality, api); zip.file(`${rootFolder}/${albumFolder}/${filename}`, blob); - - - try { - await addCoverToZipIfMissing(zip, `${rootFolder}/${albumFolder}`, track.album?.cover || album.cover, api); - } catch (e) {} - if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { try { const lyricsData = await lyricsManager.fetchLyrics(track.id); @@ -580,6 +553,7 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag await api.downloadTrack(track.id, quality, filename, { signal: controller.signal, + track: track, onProgress: (progress) => { updateDownloadProgress(track.id, progress); } diff --git a/js/metadata.js b/js/metadata.js new file mode 100644 index 0000000..aa61629 --- /dev/null +++ b/js/metadata.js @@ -0,0 +1,531 @@ +import { getExtensionForQuality } from './utils.js'; + +const VENDOR_STRING = 'Monochrome'; +const DEFAULT_TITLE = 'Unknown Title'; +const DEFAULT_ARTIST = 'Unknown Artist'; +const DEFAULT_ALBUM = 'Unknown Album'; + +/** + * Adds metadata tags to audio files (FLAC or M4A) + * @param {Blob} audioBlob - The audio file blob + * @param {Object} track - Track metadata + * @param {Object} api - API instance for fetching album art + * @param {string} quality - Audio quality + * @returns {Promise} - Audio blob with embedded metadata + */ +export async function addMetadataToAudio(audioBlob, track, api, quality) { + const extension = getExtensionForQuality(quality); + + if (extension === 'flac') { + return await addFlacMetadata(audioBlob, track, api); + } else if (extension === 'm4a') { + return await addM4aMetadata(audioBlob, track, api); + } + + // If unsupported format, return original blob + return audioBlob; +} + +/** + * Adds Vorbis comment metadata to FLAC files + */ +async function addFlacMetadata(flacBlob, track, api) { + try { + const arrayBuffer = await flacBlob.arrayBuffer(); + const dataView = new DataView(arrayBuffer); + + // Verify FLAC signature + if (!isFlacFile(dataView)) { + console.warn('Not a valid FLAC file, returning original'); + return flacBlob; + } + + // Parse FLAC structure + const blocks = parseFlacBlocks(dataView); + + // Create or update Vorbis comment block + const vorbisCommentBlock = createVorbisCommentBlock(track); + + // Fetch album artwork if available + let pictureBlock = null; + if (track.album?.cover) { + try { + pictureBlock = await createFlacPictureBlock(track.album.cover, api); + } catch (error) { + console.warn('Failed to embed album art:', error); + } + } + + // Rebuild FLAC file with new metadata + const newFlacData = rebuildFlacWithMetadata(dataView, blocks, vorbisCommentBlock, pictureBlock); + + return new Blob([newFlacData], { type: 'audio/flac' }); + } catch (error) { + console.error('Failed to add FLAC metadata:', error); + return flacBlob; + } +} + +function isFlacFile(dataView) { + // Check for "fLaC" signature at the beginning + return dataView.byteLength >= 4 && + dataView.getUint8(0) === 0x66 && // 'f' + dataView.getUint8(1) === 0x4C && // 'L' + dataView.getUint8(2) === 0x61 && // 'a' + dataView.getUint8(3) === 0x43; // 'C' +} + +function parseFlacBlocks(dataView) { + const blocks = []; + let offset = 4; // Skip "fLaC" signature + + while (offset + 4 <= dataView.byteLength) { + const header = dataView.getUint8(offset); + const isLast = (header & 0x80) !== 0; + const blockType = header & 0x7F; + + const blockSize = (dataView.getUint8(offset + 1) << 16) | + (dataView.getUint8(offset + 2) << 8) | + dataView.getUint8(offset + 3); + + // Validate block size + if (offset + 4 + blockSize > dataView.byteLength) { + console.warn('Invalid block size detected, stopping parse'); + break; + } + + blocks.push({ + type: blockType, + isLast: isLast, + size: blockSize, + offset: offset + 4, + headerOffset: offset + }); + + offset += 4 + blockSize; + + if (isLast) { + // Save the audio data offset + blocks.audioDataOffset = offset; + break; + } + } + + return blocks; +} + +function createVorbisCommentBlock(track) { + // Vorbis comment structure + const comments = []; + + // Add standard tags + if (track.title) { + comments.push(['TITLE', track.title]); + } + if (track.artist?.name) { + comments.push(['ARTIST', track.artist.name]); + } + if (track.album?.title) { + comments.push(['ALBUM', track.album.title]); + } + if (track.album?.artist?.name) { + comments.push(['ALBUMARTIST', track.album.artist.name]); + } + if (track.trackNumber) { + comments.push(['TRACKNUMBER', String(track.trackNumber)]); + } + if (track.album?.numberOfTracks) { + comments.push(['TRACKTOTAL', String(track.album.numberOfTracks)]); + } + + const releaseDateStr = track.album?.releaseDate || (track.streamStartDate ? track.streamStartDate.split('T')[0] : ''); + if (releaseDateStr) { + try { + const year = new Date(releaseDateStr).getFullYear(); + if (!isNaN(year)) { + comments.push(['DATE', String(year)]); + } + } catch (error) { + // Invalid date, skip + } + } + + if (track.copyright) { + comments.push(['COPYRIGHT', track.copyright]); + } + if (track.isrc) { + comments.push(['ISRC', track.isrc]); + } + + // Calculate total size + const vendor = VENDOR_STRING; + const vendorBytes = new TextEncoder().encode(vendor); + + let totalSize = 4 + vendorBytes.length + 4; // vendor length + vendor + comment count + + const encodedComments = comments.map(([key, value]) => { + const text = `${key}=${value}`; + const bytes = new TextEncoder().encode(text); + totalSize += 4 + bytes.length; + return bytes; + }); + + // Create buffer + const buffer = new ArrayBuffer(totalSize); + const view = new DataView(buffer); + const uint8Array = new Uint8Array(buffer); + + let offset = 0; + + // Vendor length (little-endian) + view.setUint32(offset, vendorBytes.length, true); + offset += 4; + + // Vendor string + uint8Array.set(vendorBytes, offset); + offset += vendorBytes.length; + + // Comment count (little-endian) + view.setUint32(offset, comments.length, true); + offset += 4; + + // Comments + for (const commentBytes of encodedComments) { + view.setUint32(offset, commentBytes.length, true); + offset += 4; + uint8Array.set(commentBytes, offset); + offset += commentBytes.length; + } + + return uint8Array; +} + +async function createFlacPictureBlock(coverId, api) { + try { + // Fetch album art + const coverUrl = api.getCoverUrl(coverId, '1280'); + const response = await fetch(coverUrl); + if (!response.ok) { + throw new Error('Failed to fetch album art'); + } + + const imageBlob = await response.blob(); + const imageBytes = new Uint8Array(await imageBlob.arrayBuffer()); + + // Detect MIME type from blob or use default + const mimeType = imageBlob.type || 'image/jpeg'; + const mimeBytes = new TextEncoder().encode(mimeType); + const description = ''; + const descBytes = new TextEncoder().encode(description); + + // Calculate total size + const totalSize = 4 + // picture type + 4 + mimeBytes.length + // mime length + mime + 4 + descBytes.length + // desc length + desc + 4 + // width + 4 + // height + 4 + // color depth + 4 + // indexed colors + 4 + imageBytes.length; // image length + image + + const buffer = new ArrayBuffer(totalSize); + const view = new DataView(buffer); + const uint8Array = new Uint8Array(buffer); + + let offset = 0; + + // Picture type (3 = front cover) + view.setUint32(offset, 3, false); + offset += 4; + + // MIME type length + view.setUint32(offset, mimeBytes.length, false); + offset += 4; + + // MIME type + uint8Array.set(mimeBytes, offset); + offset += mimeBytes.length; + + // Description length + view.setUint32(offset, descBytes.length, false); + offset += 4; + + // Description (empty) + if (descBytes.length > 0) { + uint8Array.set(descBytes, offset); + offset += descBytes.length; + } + + // Width (0 = unknown) + view.setUint32(offset, 0, false); + offset += 4; + + // Height (0 = unknown) + view.setUint32(offset, 0, false); + offset += 4; + + // Color depth (0 = unknown) + view.setUint32(offset, 0, false); + offset += 4; + + // Indexed colors (0 = not indexed) + view.setUint32(offset, 0, false); + offset += 4; + + // Image data length + view.setUint32(offset, imageBytes.length, false); + offset += 4; + + // Image data + uint8Array.set(imageBytes, offset); + + return uint8Array; + } catch (error) { + console.error('Failed to create FLAC picture block:', error); + return null; + } +} + +function rebuildFlacWithMetadata(dataView, blocks, vorbisCommentBlock, pictureBlock) { + const originalArray = new Uint8Array(dataView.buffer); + + // Remove old Vorbis comment and picture blocks + const filteredBlocks = blocks.filter(b => b.type !== 4 && b.type !== 6); // 4 = Vorbis, 6 = Picture + + // Calculate new file size + let newSize = 4; // "fLaC" signature + + // Add STREAMINFO and other essential blocks + for (const block of filteredBlocks) { + newSize += 4 + block.size; // header + data + } + + // Add new Vorbis comment block + newSize += 4 + vorbisCommentBlock.length; + + // Add picture block if available + if (pictureBlock) { + newSize += 4 + pictureBlock.length; + } + + // Add audio data + const audioDataOffset = blocks.audioDataOffset; + if (audioDataOffset === undefined) { + throw new Error('Invalid FLAC file structure: unable to locate audio data stream'); + } + const audioDataSize = dataView.byteLength - audioDataOffset; + newSize += audioDataSize; + + // Build new file + const newFile = new Uint8Array(newSize); + let offset = 0; + + // Write "fLaC" signature + newFile[offset++] = 0x66; // 'f' + newFile[offset++] = 0x4C; // 'L' + newFile[offset++] = 0x61; // 'a' + newFile[offset++] = 0x43; // 'C' + + // Write existing blocks (except Vorbis and Picture) + for (let i = 0; i < filteredBlocks.length; i++) { + const block = filteredBlocks[i]; + const isLast = false; // We'll add more blocks + + // Write block header + const header = (isLast ? 0x80 : 0x00) | block.type; + newFile[offset++] = header; + newFile[offset++] = (block.size >> 16) & 0xFF; + newFile[offset++] = (block.size >> 8) & 0xFF; + newFile[offset++] = block.size & 0xFF; + + // Write block data + newFile.set(originalArray.subarray(block.offset, block.offset + block.size), offset); + offset += block.size; + } + + // Write new Vorbis comment block + const vorbisHeaderOffset = offset; + const vorbisHeader = 0x04; // Vorbis comment type + newFile[offset++] = vorbisHeader; + newFile[offset++] = (vorbisCommentBlock.length >> 16) & 0xFF; + newFile[offset++] = (vorbisCommentBlock.length >> 8) & 0xFF; + newFile[offset++] = vorbisCommentBlock.length & 0xFF; + newFile.set(vorbisCommentBlock, offset); + offset += vorbisCommentBlock.length; + + let lastBlockHeaderOffset = vorbisHeaderOffset; + + // Write picture block if available + if (pictureBlock) { + const pictureHeaderOffset = offset; + const pictureHeader = 0x06; // Picture type + newFile[offset++] = pictureHeader; + newFile[offset++] = (pictureBlock.length >> 16) & 0xFF; + newFile[offset++] = (pictureBlock.length >> 8) & 0xFF; + newFile[offset++] = pictureBlock.length & 0xFF; + newFile.set(pictureBlock, offset); + offset += pictureBlock.length; + lastBlockHeaderOffset = pictureHeaderOffset; + } + + // Mark the last metadata block with the 0x80 flag + newFile[lastBlockHeaderOffset] |= 0x80; + + // Write audio data + if (audioDataSize > 0) { + newFile.set(originalArray.subarray(audioDataOffset, audioDataOffset + audioDataSize), offset); + } + + return newFile; +} + +/** + * Adds metadata to M4A files using MP4 atoms + */ +async function addM4aMetadata(m4aBlob, track, api) { + try { + const arrayBuffer = await m4aBlob.arrayBuffer(); + const dataView = new DataView(arrayBuffer); + + // Parse MP4 atoms + const atoms = parseMp4Atoms(dataView); + + // Create metadata atoms + const metadataAtoms = createMp4MetadataAtoms(track); + + // Fetch album artwork if available + if (track.album?.cover) { + try { + const coverData = await fetchAlbumArtForMp4(track.album.cover, api); + if (coverData) { + metadataAtoms.cover = coverData; + } + } catch (error) { + console.warn('Failed to embed album art in M4A:', error); + } + } + + // Rebuild MP4 file with metadata + const newMp4Data = rebuildMp4WithMetadata(dataView, atoms, metadataAtoms); + + return new Blob([newMp4Data], { type: 'audio/mp4' }); + } catch (error) { + console.error('Failed to add M4A metadata:', error); + return m4aBlob; + } +} + +function parseMp4Atoms(dataView) { + const atoms = []; + let offset = 0; + + while (offset + 8 <= dataView.byteLength) { + // MP4 atoms use big-endian byte order + let size = dataView.getUint32(offset, false); + + // Handle special size values + if (size === 0) { + // Size 0 means the atom extends to the end of the file + size = dataView.byteLength - offset; + } else if (size === 1) { + // Size 1 means 64-bit extended size follows (after the type field) + if (offset + 16 > dataView.byteLength) { + break; + } + // Read 64-bit size from offset+8 (big-endian) + const sizeHigh = dataView.getUint32(offset + 8, false); + const sizeLow = dataView.getUint32(offset + 12, false); + if (sizeHigh !== 0) { + console.warn('64-bit MP4 atoms larger than 4GB are not supported - file may be processed incompletely'); + break; + } + size = sizeLow; + } + + if (size < 8 || offset + size > dataView.byteLength) { + break; + } + + const type = String.fromCharCode( + dataView.getUint8(offset + 4), + dataView.getUint8(offset + 5), + dataView.getUint8(offset + 6), + dataView.getUint8(offset + 7) + ); + + atoms.push({ + type: type, + offset: offset, + size: size + }); + + offset += size; + } + + return atoms; +} + +function createMp4MetadataAtoms(track) { + // MP4 metadata atoms are more complex than FLAC + // We'll create basic iTunes-style metadata + + const tags = { + '©nam': track.title || DEFAULT_TITLE, + '©ART': track.artist?.name || DEFAULT_ARTIST, + '©alb': track.album?.title || DEFAULT_ALBUM, + 'aART': track.album?.artist?.name || DEFAULT_ARTIST, + }; + + if (track.trackNumber) { + tags['trkn'] = track.trackNumber; + } + + const releaseDateStr = track.album?.releaseDate || (track.streamStartDate ? track.streamStartDate.split('T')[0] : ''); + if (releaseDateStr) { + try { + const year = new Date(releaseDateStr).getFullYear(); + if (!isNaN(year)) { + tags['©day'] = String(year); + } + } catch (error) { + // Invalid date, skip + } + } + + return { tags }; +} + +async function fetchAlbumArtForMp4(coverId, api) { + try { + const coverUrl = api.getCoverUrl(coverId, '1280'); + const response = await fetch(coverUrl); + if (!response.ok) { + return null; + } + + const imageBlob = await response.blob(); + const imageBytes = new Uint8Array(await imageBlob.arrayBuffer()); + + return { + type: 'covr', + data: imageBytes + }; + } catch (error) { + console.error('Failed to fetch album art for MP4:', error); + return null; + } +} + +function rebuildMp4WithMetadata(dataView, atoms, metadataAtoms) { + // M4A metadata injection is complex and requires: + // 1. Finding the moov atom + // 2. Finding or creating the udta atom inside moov + // 3. Creating a meta atom with ilst containing all metadata + // 4. Rebuilding the file with updated atom sizes + + // For now, return the original file to avoid potential corruption + // TODO: Implement full MP4 metadata injection + const originalArray = new Uint8Array(dataView.buffer); + console.warn('M4A metadata embedding is not yet supported - downloaded file will not contain metadata tags'); + return originalArray; +} From 47d64add7257ac9f6662ad368e15152ff03532f6 Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Fri, 26 Dec 2025 13:58:28 +0100 Subject: [PATCH 4/8] Refactor cover art fetching to use centralized getCoverBlob with CORS workaround in metadata --- js/downloads.js | 49 +------------------------------------------------ js/metadata.js | 14 +++++--------- js/utils.js | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 57 deletions(-) diff --git a/js/downloads.js b/js/downloads.js index 90c186c..ad786ed 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -1,57 +1,10 @@ //js/downloads.js -import { buildTrackFilename, sanitizeForFilename, RATE_LIMIT_ERROR_MESSAGE, getTrackArtists, getTrackTitle, formatTemplate, SVG_CLOSE } from './utils.js'; +import { buildTrackFilename, sanitizeForFilename, RATE_LIMIT_ERROR_MESSAGE, getTrackArtists, getTrackTitle, formatTemplate, SVG_CLOSE, getCoverBlob } from './utils.js'; import { lyricsSettings } from './storage.js'; import { addMetadataToAudio } from './metadata.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); - - const fetchWithProxy = async (url) => { - try { - const proxyUrl = `https://corsproxy.io/?${encodeURIComponent(url)}`; - const response = await fetch(proxyUrl); - if (response.ok) return await response.blob(); - } catch (e) { - console.warn('Proxy fetch failed:', e); - } - return null; - }; - - try { - const url = api.getCoverUrl(coverId, '1280'); - // Try direct fetch first - const response = await fetch(url); - if (response.ok) { - const blob = await response.blob(); - coverCache.set(coverId, blob); - return blob; - } else { - // If direct fetch fails (e.g. 404 from SW due to CORS), try proxy - const blob = await fetchWithProxy(url); - if (blob) { - coverCache.set(coverId, blob); - return blob; - } - } - } catch (e) { - // Network error (CORS rejection not handled by SW), try proxy - const url = api.getCoverUrl(coverId, '1280'); - const blob = await fetchWithProxy(url); - if (blob) { - coverCache.set(coverId, blob); - return blob; - } - } - return null; -} /** * Adds a cover blob to a JSZip instance diff --git a/js/metadata.js b/js/metadata.js index aa61629..a2cfb39 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -1,4 +1,4 @@ -import { getExtensionForQuality } from './utils.js'; +import { getExtensionForQuality, getCoverBlob } from './utils.js'; const VENDOR_STRING = 'Monochrome'; const DEFAULT_TITLE = 'Unknown Title'; @@ -203,13 +203,11 @@ function createVorbisCommentBlock(track) { async function createFlacPictureBlock(coverId, api) { try { // Fetch album art - const coverUrl = api.getCoverUrl(coverId, '1280'); - const response = await fetch(coverUrl); - if (!response.ok) { + const imageBlob = await getCoverBlob(api, coverId); + if (!imageBlob) { throw new Error('Failed to fetch album art'); } - const imageBlob = await response.blob(); const imageBytes = new Uint8Array(await imageBlob.arrayBuffer()); // Detect MIME type from blob or use default @@ -497,13 +495,11 @@ function createMp4MetadataAtoms(track) { async function fetchAlbumArtForMp4(coverId, api) { try { - const coverUrl = api.getCoverUrl(coverId, '1280'); - const response = await fetch(coverUrl); - if (!response.ok) { + const imageBlob = await getCoverBlob(api, coverId); + if (!imageBlob) { return null; } - const imageBlob = await response.blob(); const imageBytes = new Uint8Array(await imageBlob.arrayBuffer()); return { diff --git a/js/utils.js b/js/utils.js index 4010e4d..69c8b5b 100644 --- a/js/utils.js +++ b/js/utils.js @@ -202,3 +202,52 @@ export const formatDuration = (seconds) => { } return `${minutes} min`; }; + +const coverCache = new Map(); + +/** + * Fetches and caches cover art as a Blob + */ +export async function getCoverBlob(api, coverId) { + if (!coverId) return null; + if (coverCache.has(coverId)) return coverCache.get(coverId); + + const fetchWithProxy = async (url) => { + try { + const proxyUrl = `https://corsproxy.io/?${encodeURIComponent(url)}`; + const response = await fetch(proxyUrl); + if (response.ok) return await response.blob(); + } catch (e) { + console.warn('Proxy fetch failed:', e); + } + return null; + }; + + try { + const url = api.getCoverUrl(coverId, '1280'); + // Try direct fetch first + const response = await fetch(url); + if (response.ok) { + const blob = await response.blob(); + coverCache.set(coverId, blob); + return blob; + } else { + // If direct fetch fails (e.g. 404 from SW due to CORS), try proxy + const blob = await fetchWithProxy(url); + if (blob) { + coverCache.set(coverId, blob); + return blob; + } + } + } catch (e) { + // Network error (CORS rejection not handled by SW), try proxy + const url = api.getCoverUrl(coverId, '1280'); + const blob = await fetchWithProxy(url); + if (blob) { + coverCache.set(coverId, blob); + return blob; + } + } + return null; +} + From f9bd6a8fd730c93f05c7b07a87e811cb118bc6e5 Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Fri, 26 Dec 2025 14:10:06 +0100 Subject: [PATCH 5/8] fix: aac metadata --- js/metadata.js | 428 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 399 insertions(+), 29 deletions(-) diff --git a/js/metadata.js b/js/metadata.js index a2cfb39..fc86b9d 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -394,9 +394,13 @@ async function addM4aMetadata(m4aBlob, track, api) { // Fetch album artwork if available if (track.album?.cover) { try { - const coverData = await fetchAlbumArtForMp4(track.album.cover, api); - if (coverData) { - metadataAtoms.cover = coverData; + const imageBlob = await getCoverBlob(api, track.album.cover); + if (imageBlob) { + const imageBytes = new Uint8Array(await imageBlob.arrayBuffer()); + metadataAtoms.cover = { + type: 'covr', + data: imageBytes + }; } } catch (error) { console.warn('Failed to embed album art in M4A:', error); @@ -493,35 +497,401 @@ function createMp4MetadataAtoms(track) { return { tags }; } -async function fetchAlbumArtForMp4(coverId, api) { - try { - const imageBlob = await getCoverBlob(api, coverId); - if (!imageBlob) { - return null; +function rebuildMp4WithMetadata(dataView, atoms, metadataAtoms) { + const originalArray = new Uint8Array(dataView.buffer); + + // Find moov atom + const moovAtom = atoms.find(a => a.type === 'moov'); + if (!moovAtom) { + console.warn('No moov atom found in M4A file'); + return originalArray; + } + + // Construct the new metadata block (udta -> meta -> ilst) + const newMetadataBytes = createMetadataBlock(metadataAtoms); + + // We need to insert this into the moov atom. + // If udta exists, we merge/replace. For simplicity, we'll append/create. + // Ideally, we should parse moov children to find udta. + + // 1. Calculate new sizes + // New file size = Original size + Metadata block size + // Note: If we are replacing existing metadata, this calculation would be different, + // but here we are assuming we are adding fresh or appending. + // A robust implementation would parse moov children, remove existing udta, and add new one. + + // Let's try to do it right: parse moov children + const moovChildren = parseMp4Atoms(new DataView(originalArray.buffer, moovAtom.offset + 8, moovAtom.size - 8)); + + // Filter out existing udta to replace it + const filteredMoovChildren = moovChildren.filter(a => a.type !== 'udta'); + + // Calculate new moov size + // Header (8) + Sum of other children sizes + New Metadata Block Size + let newMoovSize = 8; + for (const child of filteredMoovChildren) { + newMoovSize += child.size; + } + newMoovSize += newMetadataBytes.length; + + const sizeDiff = newMoovSize - moovAtom.size; + const newFileSize = originalArray.length + sizeDiff; + + const newFile = new Uint8Array(newFileSize); + let offset = 0; + let originalOffset = 0; + + // Copy atoms before moov + const atomsBeforeMoov = atoms.filter(a => a.offset < moovAtom.offset); + for (const atom of atomsBeforeMoov) { + newFile.set(originalArray.subarray(atom.offset, atom.offset + atom.size), offset); + offset += atom.size; + originalOffset += atom.size; + } + + // Write new moov atom + // Size + newFile[offset++] = (newMoovSize >> 24) & 0xFF; + newFile[offset++] = (newMoovSize >> 16) & 0xFF; + newFile[offset++] = (newMoovSize >> 8) & 0xFF; + newFile[offset++] = newMoovSize & 0xFF; + + // Type 'moov' + newFile[offset++] = 0x6D; + newFile[offset++] = 0x6F; + newFile[offset++] = 0x6F; + newFile[offset++] = 0x76; + + // Write preserved children of moov + for (const child of filteredMoovChildren) { + const childStart = moovAtom.offset + 8 + child.offset; // child.offset is relative to moov body start in our parseMp4Atoms helper usage? + // Wait, parseMp4Atoms returns absolute offsets usually? + // Let's verify parseMp4Atoms usage. + // When we passed a slice DataView, the offsets returned by parseMp4Atoms + // are relative to the start of that DataView (which is moov body start). + + const absoluteChildStart = moovAtom.offset + 8 + child.offset; + newFile.set(originalArray.subarray(absoluteChildStart, absoluteChildStart + child.size), offset); + offset += child.size; + } + + // Write new metadata block (udta) + newFile.set(newMetadataBytes, offset); + offset += newMetadataBytes.length; + + // Update originalOffset to skip old moov + originalOffset = moovAtom.offset + moovAtom.size; + + // Copy atoms after moov + // Adjust offsets in stco/co64 atoms if necessary? + // Changing the size of moov (or atoms before mdat) shifts the mdat offsets. + // If moov comes before mdat, we MUST update the Chunk Offset Atom (stco or co64). + // This is complex. + + // Safe strategy: If moov is AFTER mdat, we don't need to update offsets. + // If moov is BEFORE mdat, we need to shift offsets. + // Most streaming optimized files have moov before mdat. + + const mdatAtom = atoms.find(a => a.type === 'mdat'); + const moovBeforeMdat = mdatAtom && moovAtom.offset < mdatAtom.offset; + + if (moovBeforeMdat) { + // We need to update stco/co64 atoms inside the copied moov children content in newFile. + // This is getting very complicated for a simple "add metadata" feature without a proper library. + // However, we can try to find 'stco' or 'co64' in the new buffer we just wrote and offset values. + + // Let's assume we need to shift by sizeDiff. + updateChunkOffsets(newFile, offset - newMoovSize, newMoovSize, sizeDiff); + } + + // Copy remaining data (mdat etc.) + if (originalOffset < originalArray.length) { + newFile.set(originalArray.subarray(originalOffset), offset); + } + + return newFile; +} + +function createMetadataBlock(metadataAtoms) { + const { tags, cover } = metadataAtoms; + + const ilstChildren = []; + + // Text tags + for (const [key, value] of Object.entries(tags)) { + if (key === 'trkn') { + ilstChildren.push(createIntAtom(key, value)); + } else { + ilstChildren.push(createStringAtom(key, value)); } - - const imageBytes = new Uint8Array(await imageBlob.arrayBuffer()); - - return { - type: 'covr', - data: imageBytes - }; - } catch (error) { - console.error('Failed to fetch album art for MP4:', error); - return null; + } + + // Cover art + if (cover) { + ilstChildren.push(createCoverAtom(cover.data)); + } + + // Construct ilst atom + const ilstSize = 8 + ilstChildren.reduce((acc, buf) => acc + buf.length, 0); + const ilst = new Uint8Array(ilstSize); + let offset = 0; + + writeAtomHeader(ilst, offset, ilstSize, 'ilst'); + offset += 8; + + for (const child of ilstChildren) { + ilst.set(child, offset); + offset += child.length; + } + + // Construct meta atom (FullAtom, version+flags = 4 bytes) + const metaSize = 12 + ilstSize; + const meta = new Uint8Array(metaSize); + offset = 0; + + writeAtomHeader(meta, offset, metaSize, 'meta'); + offset += 8; + + meta[offset++] = 0; // Version + meta[offset++] = 0; // Flags + meta[offset++] = 0; + meta[offset++] = 0; + + meta.set(ilst, offset); + + // Construct hdlr atom (required for meta) + // "mdir" subtype, "appl" manufacturer, 0 flags/masks, empty name + // hdlr size: 4 (size) + 4 (type) + 4 (ver/flags) + 4 (pre_defined) + 4 (handler_type) + 12 (reserved) + name (string) + // Minimal valid hdlr for iTunes metadata: + const hdlrContent = new Uint8Array([ + 0, 0, 0, 0, // Version/Flags + 0, 0, 0, 0, // Pre-defined + 0x6D, 0x64, 0x69, 0x72, // 'mdir' + 0x61, 0x70, 0x70, 0x6C, // 'appl' + 0, 0, 0, 0, // Reserved + 0, 0, 0, 0, + 0, 0 // Name (empty null-term) check spec? usually simple 0 is enough + ]); + const hdlrSize = 8 + hdlrContent.length; + const hdlr = new Uint8Array(hdlrSize); + writeAtomHeader(hdlr, 0, hdlrSize, 'hdlr'); + hdlr.set(hdlrContent, 8); + + + // Construct udta atom + // udta contains meta. meta usually should contain hdlr before ilst? + // Actually, QuickTime spec says meta contains hdlr then ilst. + + const finalMetaSize = 12 + hdlrSize + ilstSize; + const finalMeta = new Uint8Array(finalMetaSize); + offset = 0; + writeAtomHeader(finalMeta, offset, finalMetaSize, 'meta'); + offset += 8; + finalMeta[offset++] = 0; // Version + finalMeta[offset++] = 0; // Flags + finalMeta[offset++] = 0; + finalMeta[offset++] = 0; + + finalMeta.set(hdlr, offset); + offset += hdlrSize; + finalMeta.set(ilst, offset); + + const udtaSize = 8 + finalMetaSize; + const udta = new Uint8Array(udtaSize); + writeAtomHeader(udta, 0, udtaSize, 'udta'); + udta.set(finalMeta, 8); + + return udta; +} + +function createStringAtom(type, value) { + const textBytes = new TextEncoder().encode(value); + const dataSize = 16 + textBytes.length; // 8 (data atom header) + 8 (flags/null) + text + const atomSize = 8 + dataSize; + + const buf = new Uint8Array(atomSize); + let offset = 0; + + // Wrapper atom (e.g., ©nam) + writeAtomHeader(buf, offset, atomSize, type); + offset += 8; + + // Data atom + writeAtomHeader(buf, offset, dataSize, 'data'); + offset += 8; + + // Data Type (1 = UTF-8 text) + Locale (0) + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 1; // Type 1 + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + + buf.set(textBytes, offset); + + return buf; +} + +function createIntAtom(type, value) { + // trkn is special: data is 8 bytes. + // reserved(2) + track(2) + total(2) + reserved(2) + const dataSize = 16 + 8; + const atomSize = 8 + dataSize; + + const buf = new Uint8Array(atomSize); + let offset = 0; + + writeAtomHeader(buf, offset, atomSize, type); + offset += 8; + + writeAtomHeader(buf, offset, dataSize, 'data'); + offset += 8; + + // Data Type (0 = implicit/int) + Locale + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; // Type 0 + buf[offset++] = 0; + buf[offset++] = 0; + 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) + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + + return buf; +} + +function createCoverAtom(imageBytes) { + const dataSize = 16 + imageBytes.length; + const atomSize = 8 + dataSize; + + const buf = new Uint8Array(atomSize); + let offset = 0; + + writeAtomHeader(buf, offset, atomSize, 'covr'); + offset += 8; + + writeAtomHeader(buf, offset, dataSize, 'data'); + offset += 8; + + // Data Type (13 = JPEG, 14 = PNG) + // We try to detect or default to JPEG (13) + let type = 13; + if (imageBytes[0] === 0x89 && imageBytes[1] === 0x50) { // PNG signature + type = 14; + } + + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = type; + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + buf[offset++] = 0; + + buf.set(imageBytes, offset); + + return buf; +} + +function writeAtomHeader(buf, offset, size, type) { + buf[offset++] = (size >> 24) & 0xFF; + buf[offset++] = (size >> 16) & 0xFF; + buf[offset++] = (size >> 8) & 0xFF; + buf[offset++] = size & 0xFF; + + for (let i = 0; i < 4; i++) { + buf[offset++] = type.charCodeAt(i); } } -function rebuildMp4WithMetadata(dataView, atoms, metadataAtoms) { - // M4A metadata injection is complex and requires: - // 1. Finding the moov atom - // 2. Finding or creating the udta atom inside moov - // 3. Creating a meta atom with ilst containing all metadata - // 4. Rebuilding the file with updated atom sizes +function updateChunkOffsets(buffer, moovOffset, moovSize, shift) { + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); - // For now, return the original file to avoid potential corruption - // TODO: Implement full MP4 metadata injection - const originalArray = new Uint8Array(dataView.buffer); - console.warn('M4A metadata embedding is not yet supported - downloaded file will not contain metadata tags'); - return originalArray; + // Scan moov for stco/co64 + // This is a naive recursive search restricted to the known moov range + + // We parse atoms starting from moov content + let offset = moovOffset + 8; // Skip moov header + const end = moovOffset + moovSize; + + findAndShiftOffsets(view, offset, end, shift); +} + +function findAndShiftOffsets(view, start, end, shift) { + let offset = start; + + while (offset + 8 <= end) { + const size = view.getUint32(offset, false); + const type = String.fromCharCode( + view.getUint8(offset + 4), + view.getUint8(offset + 5), + view.getUint8(offset + 6), + view.getUint8(offset + 7) + ); + + if (size < 8) break; + + if (type === 'trak' || type === 'mdia' || type === 'minf' || type === 'stbl') { + // Container atoms, recurse + findAndShiftOffsets(view, offset + 8, offset + size, shift); + } else if (type === 'stco') { + // Chunk Offset Atom (32-bit) + // Header (8) + Version(1) + Flags(3) + Count(4) + Entries(Count * 4) + const count = view.getUint32(offset + 12, false); + for (let i = 0; i < count; i++) { + const entryOffset = offset + 16 + (i * 4); + const oldVal = view.getUint32(entryOffset, false); + view.setUint32(entryOffset, oldVal + shift, false); + } + } else if (type === 'co64') { + // Chunk Offset Atom (64-bit) + // Header (8) + Version(1) + Flags(3) + Count(4) + Entries(Count * 8) + const count = view.getUint32(offset + 12, false); + for (let i = 0; i < count; i++) { + const entryOffset = offset + 16 + (i * 8); + // Read 64-bit int + const oldHigh = view.getUint32(entryOffset, false); + const oldLow = view.getUint32(entryOffset + 4, false); + + // Add shift (assuming shift is small enough not to overflow low 32 in a way that affects high simply?) + // Shift is Javascript number (double), up to 9007199254740991. + // 32-bit uint max is 4294967295. + + // Proper 64-bit addition + // Construct BigInt + // Note: BigInt might not be available in all older environments, but modern browsers support it. + // Fallback: simpler logic + + let newLow = oldLow + shift; + let carry = 0; + if (newLow > 0xFFFFFFFF) { + carry = Math.floor(newLow / 0x100000000); + newLow = newLow >>> 0; + } + const newHigh = oldHigh + carry; + + view.setUint32(entryOffset, newHigh, false); + view.setUint32(entryOffset + 4, newLow, false); + } + } + + offset += size; + } } From c03f1006ee6c054bcbe5b3bf6f9c8ff8f3ee3085 Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Fri, 26 Dec 2025 14:19:47 +0100 Subject: [PATCH 6/8] Force ZIP compression to STORE for all bulk downloads to improve performance and stability --- js/downloads.js | 77 +++++++++++++++---------------------------------- 1 file changed, 23 insertions(+), 54 deletions(-) diff --git a/js/downloads.js b/js/downloads.js index ad786ed..94233e3 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -193,6 +193,26 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null) { return blob; } +async function generateAndDownloadZip(zip, filename, notification, progressTotal) { + updateBulkDownloadProgress(notification, progressTotal, progressTotal, 'Creating ZIP...'); + + const zipBlob = await zip.generateAsync({ + type: 'blob', + compression: 'STORE' + }); + + const url = URL.createObjectURL(zipBlob); + const a = document.createElement('a'); + a.href = url; + a.download = `${filename}.zip`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + completeBulkDownload(notification, true); +} + export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsManager = null) { const JSZip = await loadJSZip(); const zip = new JSZip(); @@ -243,24 +263,7 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana } } - updateBulkDownloadProgress(notification, tracks.length, tracks.length, 'Creating ZIP...'); - - const zipBlob = await zip.generateAsync({ - type: 'blob', - compression: 'DEFLATE', - compressionOptions: { level: 6 } - }); - - const url = URL.createObjectURL(zipBlob); - const a = document.createElement('a'); - a.href = url; - a.download = `${folderName}.zip`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - completeBulkDownload(notification, true); + await generateAndDownloadZip(zip, folderName, notification, tracks.length); } catch (error) { completeBulkDownload(notification, false, error.message); throw error; @@ -313,24 +316,7 @@ export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyri } } - updateBulkDownloadProgress(notification, tracks.length, tracks.length, 'Creating ZIP...'); - - const zipBlob = await zip.generateAsync({ - type: 'blob', - compression: 'DEFLATE', - compressionOptions: { level: 6 } - }); - - const url = URL.createObjectURL(zipBlob); - const a = document.createElement('a'); - a.href = url; - a.download = `${folderName}.zip`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - completeBulkDownload(notification, true); + await generateAndDownloadZip(zip, folderName, notification, tracks.length); } catch (error) { completeBulkDownload(notification, false, error.message); throw error; @@ -399,24 +385,7 @@ export async function downloadDiscography(artist, api, quality, lyricsManager = } } - updateBulkDownloadProgress(notification, totalAlbums, totalAlbums, 'Creating ZIP...'); - - const zipBlob = await zip.generateAsync({ - type: 'blob', - compression: 'DEFLATE', - compressionOptions: { level: 6 } - }); - - const url = URL.createObjectURL(zipBlob); - const a = document.createElement('a'); - a.href = url; - a.download = `${rootFolder}.zip`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - completeBulkDownload(notification, true); + await generateAndDownloadZip(zip, rootFolder, notification, totalAlbums); } catch (error) { completeBulkDownload(notification, false, error.message); throw error; From 5633fb097d354cff4e76ff1cf6e0ba6557eeb8a5 Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Fri, 26 Dec 2025 14:42:31 +0100 Subject: [PATCH 7/8] FIX download notification popup width --- js/downloads.js | 2 +- styles.css | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/js/downloads.js b/js/downloads.js index 94233e3..b503d0d 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -74,7 +74,7 @@ export function addDownloadTask(trackId, track, filename, api) {
-
Starting...
+
Starting...