wip: cleanup broken metadata handling

This commit is contained in:
Julien Maille 2025-12-26 00:42:50 +01:00 committed by Julien Maille
parent f3a0e40a1a
commit e0cfaba14c
5 changed files with 88 additions and 211 deletions

View file

@ -8,7 +8,7 @@ import { LastFMScrobbler } from './lastfm.js';
import { LyricsManager, createLyricsPanel, showKaraokeView, showSyncedLyricsPanel, clearLyricsPanelSync } from './lyrics.js'; import { LyricsManager, createLyricsPanel, showKaraokeView, showSyncedLyricsPanel, clearLyricsPanelSync } from './lyrics.js';
import { createRouter, updateTabTitle } from './router.js'; import { createRouter, updateTabTitle } from './router.js';
import { initializeSettings } from './settings.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 { initializeUIInteractions } from './ui-interactions.js';
import { downloadAlbumAsZip, downloadDiscography, downloadPlaylistAsZip } from './downloads.js'; import { downloadAlbumAsZip, downloadDiscography, downloadPlaylistAsZip } from './downloads.js';
import { debounce, SVG_PLAY } from './utils.js'; import { debounce, SVG_PLAY } from './utils.js';
@ -275,7 +275,7 @@ document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('download-current-btn')?.addEventListener('click', () => { document.getElementById('download-current-btn')?.addEventListener('click', () => {
if (player.currentTrack) { if (player.currentTrack) {
downloadTrackWithMetadata(player.currentTrack, player.quality, api, lyricsManager); handleTrackAction('download', player.currentTrack, player, api, lyricsManager);
} }
}); });

View file

@ -4,6 +4,39 @@ import { lyricsSettings } from './storage.js';
const downloadTasks = new Map(); const downloadTasks = new Map();
let downloadNotificationContainer = null; 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() { async function loadJSZip() {
try { try {
@ -177,55 +210,16 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null) {
return blob; 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) { export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsManager = null) {
const JSZip = await loadJSZip(); const JSZip = await loadJSZip();
const zip = new JSZip(); const zip = new JSZip();
const template = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist} - monochrome.tf'; const coverBlob = await getCoverBlob(api, album.cover || album.album?.cover || album.coverId);
const releaseDate = album.releaseDate ? new Date(album.releaseDate) : null; 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 year = (releaseDate && !isNaN(releaseDate.getTime())) ? releaseDate.getFullYear() : '';
const folderName = formatTemplate(template, { const folderName = formatTemplate(localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}', {
albumTitle: album.title, albumTitle: album.title,
albumArtist: album.artist?.name, albumArtist: album.artist?.name,
year: year year: year
@ -234,8 +228,7 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana
const notification = createBulkDownloadNotification('album', album.title, tracks.length); const notification = createBulkDownloadNotification('album', album.title, tracks.length);
try { try {
addCoverBlobToZip(zip, folderName, coverBlob);
const albumCoverId = album.cover || album.album?.cover || album.coverId || null;
for (let i = 0; i < tracks.length; i++) { for (let i = 0; i < tracks.length; i++) {
const track = tracks[i]; const track = tracks[i];
@ -309,8 +302,7 @@ export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyri
const JSZip = await loadJSZip(); const JSZip = await loadJSZip();
const zip = new JSZip(); const zip = new JSZip();
const template = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist} - monochrome.tf'; const folderName = formatTemplate(localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}', {
const folderName = formatTemplate(template, {
albumTitle: playlist.title, albumTitle: playlist.title,
albumArtist: 'Playlist', albumArtist: 'Playlist',
year: new Date().getFullYear() year: new Date().getFullYear()
@ -327,10 +319,11 @@ export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyri
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle); updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
try { try {
const coverBlob = await getCoverBlob(api, track.album?.cover);
const blob = await downloadTrackBlob(track, quality, api); const blob = await downloadTrackBlob(track, quality, api);
zip.file(`${folderName}/${filename}`, blob); zip.file(`${folderName}/${filename}`, blob);
// add metadata JSON addCoverBlobToZip(zip, folderName, coverBlob);
try { try {
const meta = buildTrackMetadata(track, api); const meta = buildTrackMetadata(track, api);
const metaFilename = filename.replace(/\.[^.]+$/, '.json'); const metaFilename = filename.replace(/\.[^.]+$/, '.json');
@ -391,8 +384,7 @@ export async function downloadDiscography(artist, api, quality, lyricsManager =
const JSZip = await loadJSZip(); const JSZip = await loadJSZip();
const zip = new JSZip(); const zip = new JSZip();
const template = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist} - monochrome.tf'; const rootFolder = `${sanitizeForFilename(artist.name)} discography`;
const rootFolder = `${sanitizeForFilename(artist.name)} discography - monochrome.tf`;
const allReleases = [...(artist.albums || []), ...(artist.eps || [])]; const allReleases = [...(artist.albums || []), ...(artist.eps || [])];
const totalAlbums = allReleases.length; const totalAlbums = allReleases.length;
@ -406,7 +398,10 @@ export async function downloadDiscography(artist, api, quality, lyricsManager =
try { try {
const { album: fullAlbum, tracks } = await api.getAlbum(album.id); 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 year = (releaseDate && !isNaN(releaseDate.getTime())) ? releaseDate.getFullYear() : '';
const albumFolder = formatTemplate(template, { const albumFolder = formatTemplate(template, {
@ -415,6 +410,8 @@ export async function downloadDiscography(artist, api, quality, lyricsManager =
year: year year: year
}); });
addCoverBlobToZip(zip, `${rootFolder}/${albumFolder}`, coverBlob);
for (const track of tracks) { for (const track of tracks) {
const filename = buildTrackFilename(track, quality); const filename = buildTrackFilename(track, quality);
@ -422,13 +419,7 @@ export async function downloadDiscography(artist, api, quality, lyricsManager =
const blob = await downloadTrackBlob(track, quality, api); const blob = await downloadTrackBlob(track, quality, api);
zip.file(`${rootFolder}/${albumFolder}/${filename}`, blob); 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 { try {
await addCoverToZipIfMissing(zip, `${rootFolder}/${albumFolder}`, track.album?.cover || album.cover, api); await addCoverToZipIfMissing(zip, `${rootFolder}/${albumFolder}`, track.album?.cover || album.cover, api);
@ -562,102 +553,25 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
api api
); );
// Manually fetch the stream so we can include metadata and cover in a ZIP await api.downloadTrack(track.id, quality, filename, {
const lookup = await api.getTrack(track.id, quality); signal: controller.signal,
let streamUrl; onProgress: (progress) => {
updateDownloadProgress(track.id, progress);
if (lookup.originalTrackUrl) {
streamUrl = lookup.originalTrackUrl;
} else {
streamUrl = api.extractStreamUrlFromManifest(lookup.info.manifest);
if (!streamUrl) {
throw new Error('Could not resolve stream URL');
} }
}
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' }); completeDownloadTask(track.id, true);
// 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) {}
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
try { try {
const lyricsData = await lyricsManager.fetchLyrics(track.id); const lyricsData = await lyricsManager.fetchLyrics(track.id);
if (lyricsData) { if (lyricsData) {
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track); lyricsManager.downloadLRC(lyricsData, track);
if (lrcContent) {
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
zip.file(lrcFilename, lrcContent);
}
} }
} catch (error) { } catch (error) {
console.log('Could not download lyrics for track'); 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) { } catch (error) {
if (error.name !== 'AbortError') { if (error.name !== 'AbortError') {
const errorMsg = error.message === RATE_LIMIT_ERROR_MESSAGE const errorMsg = error.message === RATE_LIMIT_ERROR_MESSAGE

View file

@ -1,7 +1,7 @@
//js/events.js //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 { 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 { lyricsSettings } from './storage.js';
import { updateTabTitle } from './router.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) { export function initializeTrackInteractions(player, api, mainContent, contextMenu, lyricsManager) {
let contextTrack = null; let contextTrack = null;
@ -300,19 +316,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
const trackItem = actionBtn.closest('.track-item'); const trackItem = actionBtn.closest('.track-item');
if (trackItem) { if (trackItem) {
const track = trackDataStore.get(trackItem); const track = trackDataStore.get(trackItem);
const action = actionBtn.dataset.action; handleTrackAction(actionBtn.dataset.action, track, player, api, lyricsManager);
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);
}
} }
return; return;
} }
@ -367,19 +371,9 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
contextMenu.addEventListener('click', async e => { contextMenu.addEventListener('click', async e => {
e.stopPropagation(); e.stopPropagation();
const action = e.target.dataset.action; const action = e.target.dataset.action;
if (action && contextTrack) {
if (action === 'play-next' && contextTrack) { await handleTrackAction(action, contextTrack, player, api, lyricsManager);
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);
} }
contextMenu.style.display = 'none'; contextMenu.style.display = 'none';
}); });
@ -451,33 +445,3 @@ function positionMenu(menu, x, y, anchorRect = null) {
menu.style.left = `${left}px`; menu.style.left = `${left}px`;
menu.style.visibility = 'visible'; 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);
}
}
}

View file

@ -215,7 +215,7 @@ export function initializeSettings(scrobbler, player, api, ui) {
// ZIP folder template // ZIP folder template
const zipFolderTemplate = document.getElementById('zip-folder-template'); const zipFolderTemplate = document.getElementById('zip-folder-template');
if (zipFolderTemplate) { 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) => { zipFolderTemplate.addEventListener('change', (e) => {
localStorage.setItem('zip-folder-template', e.target.value); localStorage.setItem('zip-folder-template', e.target.value);
}); });

View file

@ -836,5 +836,4 @@ export class UIRenderer {
} }
}); });
} }
} }