refactor(downloads): cleanup downloads and add mp4 stik atom

This commit is contained in:
Daniel 2026-03-23 17:02:18 -05:00
parent 23c5baae5f
commit 80cd8b2f9b
10 changed files with 417 additions and 208 deletions

150
js/api.js
View file

@ -23,6 +23,15 @@ import { resolveDownloadTotalBytes } from './downloadProgressUtils.js';
import { readableStreamIterator } from './readableStreamIterator.js';
import { HiFiClient, TidalResponse } from './HiFi.ts';
import { isIos, isSafari } from './platform-detection.js';
import {
TrackAlbum,
EnrichedAlbum,
EnrichedTrack,
ReplayGain,
PlaybackInfo,
Track,
Album,
} from './container-classes.js';
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
export { resolveDownloadTotalBytes };
@ -206,7 +215,7 @@ export class LosslessAPI {
if (track.type && typeof track.type === 'string') {
const lowType = track.type.toLowerCase();
if (lowType === 'video' || lowType === 'track') {
if (lowType.includes('video') || lowType.includes('track')) {
normalized = { ...track, type: lowType };
}
}
@ -771,6 +780,16 @@ export class LosslessAPI {
});
}
tracks = tracks.map((t) => {
if (t.album) {
t.album = Object.assign(new TrackAlbum(), t.album);
}
return Object.assign(new Track(), t);
});
album = Object.assign(new Album(), album);
const result = { album, tracks };
if (!(response instanceof TidalResponse)) {
@ -883,6 +902,14 @@ export class LosslessAPI {
// Removed to reduce API load. Playlists can be very large.
// tracks = await this.enrichTracksWithAlbumDates(tracks);
tracks = tracks.map((t) => {
if (t.album) {
t.album = Object.assign(new TrackAlbum(), t.album);
}
return Object.assign(new Track(), t);
});
const result = { playlist, tracks };
if (!(response instanceof TidalResponse)) {
@ -911,6 +938,14 @@ export class LosslessAPI {
// Limited to reduce API load
tracks = await this.enrichTracksWithAlbumDates(tracks, 10);
tracks = tracks.map((t) => {
if (t.album) {
t.album = Object.assign(new TrackAlbum(), t.album);
}
return Object.assign(new Track(), t);
});
const mix = {
id: mixData.id,
title: mixData.title,
@ -1013,7 +1048,7 @@ export class LosslessAPI {
const isTrack = (v) => v?.id && v.duration;
const isAlbum = (v) => v?.id && 'numberOfTracks' in v;
const isVideo = (v) => v?.id && v.type === 'VIDEO';
const isVideo = (v) => v?.id && !!v.type?.toLowerCase().includes('video');
const scan = (value, visited) => {
if (!value || typeof value !== 'object' || visited.has(value)) return;
@ -1600,6 +1635,74 @@ export class LosslessAPI {
return streamUrl;
}
async enrichTrack(input, { downloadQuality = 'HI_RES_LOSSLESS' }) {
const id = input?.id || input;
const track = typeof input === 'object' ? input : await this.getTrack(id, downloadQuality);
const isVideo = track?.type?.toLowerCase().includes('video');
downloadQuality = isCustomFormat(downloadQuality) ? 'LOSSLESS' : downloadQuality;
let lookup;
if (isVideo) {
lookup = await this.getVideo(id);
} else {
lookup = Object.assign(new PlaybackInfo(), await this.getTrack(id, downloadQuality));
}
if (input instanceof EnrichedTrack) {
return {
lookup,
enrichedTrack: input,
isVideo,
};
}
const enrichedTrack = { ...track };
if (lookup.info) {
enrichedTrack.replayGain = Object.assign(new ReplayGain(), {
trackReplayGain: lookup.info.trackReplayGain,
trackPeakAmplitude: lookup.info.trackPeakAmplitude,
albumReplayGain: lookup.info.albumReplayGain,
albumPeakAmplitude: lookup.info.albumPeakAmplitude,
});
}
if (track.album?.id && (track.album?.totalDiscs == null || track.album?.numberOfTracksOnDisc == null)) {
try {
const albumData = await this.getAlbum(track.album.id);
enrichedTrack.album = Object.assign(new EnrichedAlbum(), {
...albumData.album,
...enrichedTrack.album,
});
if (albumData.tracks?.length > 0) {
const discTrackCounts = new Map();
let maxDiscNumber = 0;
for (const t of albumData.tracks) {
const dn = getTrackDiscNumber(t);
discTrackCounts.set(dn, (discTrackCounts.get(dn) || 0) + 1);
if (dn > maxDiscNumber) maxDiscNumber = dn;
}
const totalDiscs = maxDiscNumber || 1;
const discNumber = getTrackDiscNumber(track);
enrichedTrack.album = Object.assign(new EnrichedAlbum(), {
...(enrichedTrack.album || {}),
totalDiscs: track.album?.totalDiscs ?? totalDiscs,
numberOfTracksOnDisc: track.album?.numberOfTracksOnDisc ?? discTrackCounts.get(discNumber),
});
}
} catch (e) {
console.warn('Failed to fetch album for disc info:', e);
}
}
if (!(enrichedTrack.album instanceof EnrichedAlbum)) {
enrichedTrack.album = Object.assign(new TrackAlbum(), enrichedTrack.album);
}
return { lookup, enrichedTrack: Object.assign(new EnrichedTrack(), enrichedTrack), isVideo };
}
/**
* Downloads a track or video from TIDAL in the specified quality.
*
@ -1633,18 +1736,12 @@ export class LosslessAPI {
const { onProgress, track, calculateDashBytes = true } = options;
const prefetchPromises = prefetchMetadataObjects(track, this);
const isVideo = track?.type === 'video';
try {
// Custom FFMPEG formats are not native TIDAL qualities; download LOSSLESS and transcode
const downloadQuality = isCustomFormat(quality) ? 'LOSSLESS' : quality;
let lookup;
if (isVideo) {
lookup = await this.getVideo(id);
} else {
lookup = await this.getTrack(id, downloadQuality);
}
const { lookup, enrichedTrack, isVideo } = await this.enrichTrack(track, { downloadQuality });
let streamUrl;
let blob;
@ -1776,41 +1873,6 @@ export class LosslessAPI {
message: 'Adding metadata...',
});
const enrichedTrack = { ...track };
if (lookup.info) {
enrichedTrack.replayGain = {
trackReplayGain: lookup.info.trackReplayGain,
trackPeakAmplitude: lookup.info.trackPeakAmplitude,
albumReplayGain: lookup.info.albumReplayGain,
albumPeakAmplitude: lookup.info.albumPeakAmplitude,
};
}
if (track.album?.id && (track.album?.totalDiscs == null || track.album?.numberOfTracksOnDisc == null)) {
try {
const albumData = await this.getAlbum(track.album.id);
if (albumData.tracks?.length > 0) {
const discTrackCounts = new Map();
let maxDiscNumber = 0;
for (const t of albumData.tracks) {
const dn = getTrackDiscNumber(t);
discTrackCounts.set(dn, (discTrackCounts.get(dn) || 0) + 1);
if (dn > maxDiscNumber) maxDiscNumber = dn;
}
const totalDiscs = maxDiscNumber || 1;
const discNumber = getTrackDiscNumber(track);
enrichedTrack.album = {
...(enrichedTrack.album || {}),
totalDiscs: track.album?.totalDiscs ?? totalDiscs,
numberOfTracksOnDisc:
track.album?.numberOfTracksOnDisc ?? discTrackCounts.get(discNumber),
};
}
} catch (e) {
console.warn('Failed to fetch album for disc info:', e);
}
}
onProgress?.(new DownloadProgress('Adding metadata'));
try {
if (isVideo) {

View file

@ -7,7 +7,7 @@ import { readableStreamIterator } from './readableStreamIterator';
export interface WriterEntry {
name: string;
lastModified: Date;
input: Blob | string | ArrayBuffer | Uint8Array;
input: Blob | File | string | ArrayBuffer | Uint8Array;
}
/** Minimal interface for the Neutralino bridge used by ZipNeutralinoWriter */
@ -47,7 +47,7 @@ export interface IBulkDownloadWriter {
/**
* Triggers individual downloads for each file entry, one after another.
*/
export class SequentialFileWriter implements IBulkDownloadWriter {
class SequentialFileWriter implements IBulkDownloadWriter {
constructor() {}
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
@ -64,15 +64,20 @@ export class SequentialFileWriter implements IBulkDownloadWriter {
continue;
}
if (file.input instanceof Blob) {
if (file.input instanceof Blob || file.input instanceof File) {
triggerDownload(file.input, name);
} else {
triggerDownload(new Blob([file.input as BlobPart]), name);
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
}
const sequentialFileWriter = new SequentialFileWriter();
export { sequentialFileWriter as SequentialFileWriter };
/**
* Streams a ZIP archive to a file via the File System Access API.
* Prompts the user to choose a save location with showSaveFilePicker.

113
js/container-classes.ts Normal file
View file

@ -0,0 +1,113 @@
export class ReplayGain {
trackReplayGain: number;
albumReplayGain: number;
trackPeakAmplitude: number;
albumPeakAmplitude: number;
}
export class Track {
accessType: string;
adSupportedStreamReady: boolean;
album: TrackAlbum;
allowStreaming: true;
artist: Artist;
artists: Artist[];
audioModes: string[];
audioQuality: string;
bpm: number;
copyright: string;
djReady: boolean;
duration: number;
explicit: boolean;
id: number;
isrc: string;
key: string;
keyScale?: string;
mediaMetadata: MediaMetadata;
mixes: Record<string, string>;
payToStream: boolean;
peak: number;
popularity: number;
premiumStreamingOnly: boolean;
replayGain: number;
spotlighted: boolean;
stemReady: boolean;
streamStartDate: string;
title: string;
trackNumber: number;
type?: string;
upload: boolean;
url: string;
version?: string;
volumeNumber: number;
}
export class PlaybackInfo extends ReplayGain {
trackId: number;
assetPresentation: string;
audioMode: string;
audioQuality: string;
manifestMimeType: string;
manifestHash: string;
manifest: string;
bitDepth: number;
sampleRate: number;
}
export class MediaMetadata {
tags: string[];
}
export class Artist {
handle: any;
id: number;
name: string;
picture: string;
type: string;
}
export class EnrichedTrack extends Track {
declare album: TrackAlbum | EnrichedAlbum;
declare replayGain: any | ReplayGain;
}
export class TrackAlbum {
cover: string;
id: number;
title: string;
vibrantColor: string;
videoCover?: string;
}
export class Album extends TrackAlbum {
adSupportedStreamReady: boolean;
allowStreaming: boolean;
artist: Artist;
artists: Artist[];
audioModes: string[];
audioQuality: string;
copyright: string;
djReady: boolean;
duration: number;
explicit: boolean;
mediaMetadata: MediaMetadata;
numberOfTracks: number;
numberOfVideos: number;
numberOfVolumes: number;
popularity: number;
premiumStreamingOnly: boolean;
releaseDate?: string;
stemReady: boolean;
streamReady: boolean;
streamStartDate: string;
type: string;
upc: string;
upload: boolean;
url: string;
version?: string;
}
export class EnrichedAlbum extends Album {
totalDiscs?: number;
numberOfTracksOnDisc?: number;
}

View file

@ -1,4 +1,5 @@
//js/downloads.js
//@ts-check
import {
buildTrackFilename,
sanitizeForFilename,
@ -28,6 +29,8 @@ import { DownloadProgress, ProgressMessage, SegmentedDownloadProgress } from './
import { db } from './db.js';
import { modernSettings } from './ModernSettings.js';
import { SVG_CLOSE } from './icons.ts';
import { MusicAPI } from './music-api.js';
import { LyricsManager } from './lyrics.js';
const downloadTasks = new Map();
const bulkDownloadTasks = new Map();
@ -332,7 +335,7 @@ async function downloadTrackBlob(track, quality, api, signal = null, onProgress
return { blob, extension };
}
async function bulkDownload(
async function bulkDownload({
tracks,
folderName,
api,
@ -342,8 +345,8 @@ async function bulkDownload(
writer,
coverBlob = null,
type = 'playlist',
metadata = null
) {
metadata = null,
}) {
const { abortController } = bulkDownloadTasks.get(notification);
const signal = abortController.signal;
@ -648,7 +651,7 @@ async function createBulkWriter(folderName) {
}
if (method === 'individual') {
return new SequentialFileWriter();
return SequentialFileWriter;
}
// method === 'zip' (or folder picker unavailable as fallback)
if (!forceZipBlob && hasFileSystemAccess) {
@ -657,26 +660,27 @@ async function createBulkWriter(folderName) {
return new ZipBlobWriter(`${folderName}.zip`);
}
async function startBulkDownload(
async function startBulkDownload({
tracks,
defaultName,
folderName = '',
api,
quality,
lyricsManager,
lyricsManager = LyricsManager.instance,
type,
name,
coverBlob = null,
metadata = null
) {
metadata = null,
single = false,
}) {
const notification = createBulkDownloadNotification(type, name, tracks.length);
try {
const writer = await createBulkWriter(defaultName);
const writer = single ? await createSingleTrackFolderWriter() : await createBulkWriter(folderName);
if (writer) {
await bulkDownload(
await bulkDownload({
tracks,
defaultName,
folderName,
api,
quality,
lyricsManager,
@ -684,8 +688,8 @@ async function startBulkDownload(
writer,
coverBlob,
type,
metadata
);
metadata,
});
}
completeBulkDownload(notification, true);
@ -706,8 +710,16 @@ async function startBulkDownload(
export async function downloadTracks(tracks, api, quality, lyricsManager = null) {
const folderName = `Queue - ${new Date().toISOString().slice(0, 10)}`;
await startBulkDownload(tracks, folderName, api, quality, lyricsManager, 'queue', 'Queue', null, {
title: 'Queue',
await startBulkDownload({
tracks,
folderName,
quality,
type: 'queue',
name: 'Queue',
metadata: {
title: 'Queue',
},
api,
});
}
@ -724,17 +736,16 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana
});
const coverBlob = await getCoverBlob(api, album.cover || album.album?.cover || album.coverId);
await startBulkDownload(
await annotateTracksWithDiscInfo(tracks, api),
await startBulkDownload({
tracks: await annotateTracksWithDiscInfo(tracks, api),
folderName,
api,
quality,
lyricsManager,
'album',
album.title,
type: 'album',
name: album.title,
coverBlob,
album
);
metadata: album,
api,
});
}
export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyricsManager = null) {
@ -746,17 +757,16 @@ export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyri
const representativeTrack = tracks.find((t) => t.album?.cover);
const coverBlob = await getCoverBlob(api, representativeTrack?.album?.cover);
await startBulkDownload(
await startBulkDownload({
tracks,
folderName,
api,
quality,
lyricsManager,
'playlist',
playlist.title,
type: 'playlist',
name: playlist.title,
coverBlob,
playlist
);
metadata: playlist,
api,
});
}
export async function downloadDiscography(artist, selectedReleases, api, quality, lyricsManager = null) {
@ -922,16 +932,22 @@ function createBulkDownloadNotification(type, name, _totalItems) {
notifEl.dataset.bulkType = type;
notifEl.dataset.bulkName = name;
const typeLabel =
type === 'album'
? 'Album'
: type === 'playlist'
? 'Playlist'
: type === 'liked'
? 'Liked Tracks'
: type === 'queue'
? 'Queue'
: 'Discography';
const typeLabel = (() => {
switch (type) {
case 'album':
return 'Album';
case 'playlist':
return 'Playlist';
case 'liked':
return 'Liked Tracks';
case 'queue':
return 'Queue';
case 'discography':
return 'Discography';
default:
return '';
}
})();
notifEl.innerHTML = `
<div style="display: flex; align-items: start; gap: 0.75rem;">
@ -1024,53 +1040,7 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
return;
}
let enrichedTrack = {
...track,
artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null),
};
try {
const fullTrack = await api.getTrackMetadata(track.id);
if (fullTrack) {
enrichedTrack = {
...fullTrack,
...enrichedTrack,
artist: enrichedTrack.artist || fullTrack.artist,
album: {
...(fullTrack.album || {}),
...(enrichedTrack.album || {}),
},
discNumber: enrichedTrack.discNumber ?? fullTrack.discNumber,
volumeNumber: enrichedTrack.volumeNumber ?? fullTrack.volumeNumber,
};
}
} catch {
// Continue with available track payload
}
if (enrichedTrack.album?.id) {
try {
const albumData = await api.getAlbum(enrichedTrack.album.id);
if (albumData.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist)) {
enrichedTrack.album = {
...enrichedTrack.album,
...albumData.album,
};
}
if (albumData.tracks?.length > 0) {
const { totalDiscs, tracksPerDisc } = await computeDiscInfo(albumData.tracks, api);
const discNumber = getTrackDiscNumber(enrichedTrack) || 1;
enrichedTrack.album = {
...enrichedTrack.album,
totalDiscs,
numberOfTracksOnDisc: tracksPerDisc.get(discNumber),
};
}
} catch (error) {
console.warn('Failed to fetch album data for metadata:', error);
}
}
const { enrichedTrack } = await api.tidalAPI.enrichTrack(track, { downloadQuality: quality });
const filename = buildTrackFilename(enrichedTrack, quality);
const controller = abortController || new AbortController();
@ -1079,74 +1049,67 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
try {
// Resolve the folder writer before registering the download task so that
// any permission prompt (requestPermission) shows before the UI task appears.
const folderWriter = await createSingleTrackFolderWriter();
const folderWriter = (await createSingleTrackFolderWriter()) || SequentialFileWriter;
addDownloadTask(track.id, enrichedTrack, filename, api, controller);
// Try to write directly to the configured folder when the feature is enabled.
if (folderWriter) {
// Download the blob (metadata already applied inside downloadTrack)
const blob = await api.downloadTrack(track.id, quality, filename, {
signal: controller.signal,
track: enrichedTrack,
onProgress: (progress) => {
updateDownloadProgress(track.id, progress);
},
calculateDashBytes: true,
triggerDownload: false,
});
// Download the blob (metadata already applied inside downloadTrack)
const blob = await api.downloadTrack(track.id, quality, filename, {
signal: controller.signal,
track: enrichedTrack,
onProgress: (progress) => {
updateDownloadProgress(track.id, progress);
},
calculateDashBytes: true,
triggerDownload: false,
});
const currentExtension = filename.split('.').pop()?.toLowerCase();
const finalFilename = buildTrackFilename(track, quality, await getExtensionFromBlob(blob))
.split('/')
.pop();
const finalFilename = buildTrackFilename(track, quality, await getExtensionFromBlob(blob))
.split('/')
.pop();
// Compute a subfolder path using the same template as bulk downloads so
// the track lands in e.g. "Album Title - Artist/" instead of the folder root.
const releaseDateStr =
enrichedTrack.album?.releaseDate ||
(enrichedTrack.streamStartDate ? enrichedTrack.streamStartDate.split('T')[0] : '');
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
const releaseYear = releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate.getFullYear() : '';
const subFolder = formatPathTemplate(modernSettings.folderTemplate, {
albumTitle: enrichedTrack.album?.title,
albumArtist: enrichedTrack.album?.artist?.name || enrichedTrack.artist?.name,
year: releaseYear,
});
const entryName = subFolder ? `${subFolder}/${finalFilename}` : finalFilename;
// Compute a subfolder path using the same template as bulk downloads so
// the track lands in e.g. "Album Title - Artist/" instead of the folder root.
const releaseDateStr =
enrichedTrack.album?.releaseDate ||
(enrichedTrack.streamStartDate ? enrichedTrack.streamStartDate.split('T')[0] : '');
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
const releaseYear = releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate.getFullYear() : '';
const subFolder = formatPathTemplate(modernSettings.folderTemplate, {
albumTitle: enrichedTrack.album?.title,
albumArtist: enrichedTrack.album?.artist?.name || enrichedTrack.artist?.name,
year: releaseYear,
});
const entryName = subFolder ? `${subFolder}/${finalFilename}` : finalFilename;
// Write to folder using IBulkDownloadWriter.write() via singleWriterEntry().
await folderWriter.write(singleWriterEntry({ name: entryName, lastModified: new Date(), input: blob }));
// If the target is the local media folder, do a cheap partial update:
// pass the downloaded blob and base filename so only this one track's metadata
// is read and inserted into localFilesCache instead of re-walking the whole folder.
if (modernSettings.bulkDownloadMethod === 'local') {
window.refreshLocalMediaFolder?.(blob, finalFilename);
}
} else {
await api.downloadTrack(track.id, quality, filename, {
signal: controller.signal,
track: enrichedTrack,
onProgress: (progress) => {
updateDownloadProgress(track.id, progress);
},
calculateDashBytes: true,
});
}
completeDownloadTask(track.id, true);
// Write to folder using IBulkDownloadWriter.write() via singleWriterEntry().
await folderWriter.write(singleWriterEntry({ name: entryName, lastModified: new Date(), input: blob }));
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
try {
const lyricsData = await lyricsManager.fetchLyrics(track.id, track);
if (lyricsData) {
lyricsManager.downloadLRC(lyricsData, track);
await folderWriter.write(
singleWriterEntry({
name: [...entryName.split('.').slice(0, -1), 'lrc'].join('.'),
lastModified: new Date(),
input: lyricsManager.getLRC(lyricsData, track),
})
);
}
} catch {
console.log('Could not download lyrics for track');
}
}
// If the target is the local media folder, do a cheap partial update:
// pass the downloaded blob and base filename so only this one track's metadata
// is read and inserted into localFilesCache instead of re-walking the whole folder.
if (modernSettings.bulkDownloadMethod === 'local') {
window.refreshLocalMediaFolder?.(blob, finalFilename);
}
completeDownloadTask(track.id, true);
} catch (error) {
if (error.name !== 'AbortError') {
const errorMsg =
@ -1160,5 +1123,12 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
export async function downloadLikedTracks(tracks, api, quality, lyricsManager = null) {
const folderName = `Liked Tracks - ${new Date().toISOString().slice(0, 10)}`;
await startBulkDownload(tracks, folderName, api, quality, lyricsManager, 'liked', 'Liked Tracks');
await startBulkDownload({
tracks,
folderName,
quality,
type: 'liked',
name: 'Liked Tracks',
api,
});
}

View file

@ -3,6 +3,10 @@ import coreJs from '!/@ffmpeg/core/dist/esm/ffmpeg-core.js?blob-url';
import coreWasm from '!/@ffmpeg/core/dist/esm/ffmpeg-core.wasm?blob-url';
import { FfmpegProgress } from './ffmpeg.types';
/**
* @typedef {import('./ffmpeg.types.ts').FfmpegProgress} FfmpegProgress
*/
class FfmpegError extends Error {
constructor(message) {
super(message);
@ -31,7 +35,7 @@ export function loadFfmpeg() {
* @param {string[]} args
* @param {string} outputName
* @param {string} outputMime
* @param {(progress: import('./ffmpeg.types.ts').FfmpegProgress) => void} onProgress
* @param {(progress: FfmpegProgress) => void} onProgress
* @param {AbortSignal|null} signal
* @param {Array<{name: string, data: ArrayBuffer | Uint8Array}>} extraFiles
* @returns {Promise<Blob>} Encoded audio blob
@ -126,7 +130,7 @@ async function ffmpegWorker(
* @param {string[]} [args=[]] - FFmpeg command-line arguments
* @param {string} [outputName='output'] - Name of the output file
* @param {string} [outputMime='application/octet-stream'] - MIME type of the output
* @param {(progress: import('./ffmpeg.types.ts').FfmpegProgress) => void} [onProgress=null] - Optional callback for progress updates
* @param {(progress: FfmpegProgress) => void} [onProgress=null] - Optional callback for progress updates
* @param {AbortSignal|null} [signal=null] - Optional abort signal to cancel encoding
* @param {Array} [extraFiles=[]] - Additional files to provide to FFmpeg
* @returns {Promise<Blob>} Encoded audio blob

View file

@ -489,18 +489,24 @@ export class LyricsManager {
return lrc;
}
downloadLRC(lyricsData, track) {
getLRC(lyricsData, track) {
const lrcContent = this.generateLRCContent(lyricsData, track);
if (!lrcContent) {
alert('No synced lyrics available for this track');
return;
}
const blob = new Blob([lrcContent], { type: 'application/octet-stream' });
return new File([lrcContent], buildTrackFilename(track, 'LOSSLESS').replace(/\.flac$/, '.lrc'), {
type: 'application/octet-stream',
});
}
downloadLRC(lyricsData, track) {
const blob = this.getLRC(lyricsData, track);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = buildTrackFilename(track, 'LOSSLESS').replace(/\.flac$/, '.lrc');
a.download = blob.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);

View file

@ -1,7 +1,13 @@
import { getCoverBlob, getTrackTitle, getFullArtistString, getMimeType, getTrackCoverId } from './utils.js';
import { addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts';
import { doTimed, doTimedAsync } from './doTimed.ts';
import { LyricsManager } from './lyrics.js';
import { Mp4Stik } from './taglib.types.ts';
/**
* @typedef {import('./container-classes.ts').Track} Track
* @typedef {import('./container-classes.ts').EnrichedTrack} EnrichedTrack
* @typedef {import("./taglib.types.ts").TagLibMetadata} TagLibMetadata
*/
export function prefetchMetadataObjects(track, api, coverBlob = null) {
const coverId = getTrackCoverId(track);
@ -18,7 +24,7 @@ export function prefetchMetadataObjects(track, api, coverBlob = null) {
/**
* Adds metadata tags to audio files (FLAC, M4A or MP3)
* @param {Blob} audioBlob - The audio file blob
* @param {Object} track - Track metadata
* @param {Track | EnrichedTrack} track - Track metadata
* @param {Object} api - API instance for fetching album art
* @param {string} quality - Audio quality
* @returns {Promise<Blob>} - Audio blob with embedded metadata
@ -27,7 +33,7 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet
const { coverFetch, lyricsFetch } = prefetchPromises;
/**
* @type {import("./taglib.worker.ts").TagLibMetadata}
* @type {TagLibMetadata}
*/
const data = {};
@ -42,7 +48,17 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet
data.totalDiscs = track.album?.totalDiscs;
data.copyright = track.copyright;
data.isrc = track.isrc;
data.upc = track.album?.upc;
data.explicit = Boolean(track.explicit);
data.stik = track.type?.toLowerCase().includes('video') ? Mp4Stik.MusicVideo : Mp4Stik.Normal;
data.extra = {
TIDAL_TRACK_ID: track.id ? String(track.id) : undefined,
TIDAL_ALBUM_ID: track.album?.id ? String(track.album?.id) : undefined,
TIDAL_TRACK_URL: track.url?.trim() || undefined,
TIDAL_ALBUM_URL: track.album?.url?.trim() || undefined,
ALBUM_RELEASE_DATE: track.album?.releaseDate?.trim() || undefined,
TIDAL_DATA: JSON.stringify(track, null, 2).replace(/\n/g, '\r\n'),
};
if (track.bpm != null) {
const bpm = Number(track.bpm);

View file

@ -1,5 +1,9 @@
import { ffmpeg } from './ffmpeg';
/**
* @typedef {import('./ffmpeg.types.ts').FfmpegProgress} FfmpegProgress
*/
class MP3EncodingError extends Error {
constructor(message) {
super(message);
@ -11,7 +15,7 @@ class MP3EncodingError extends Error {
/**
*
* @param {Blob} audioBlob
* @param {(progress: import('./ffmpeg.types.ts').FfmpegProgress) => void} [onProgress=null]
* @param {(progress: FfmpegProgress) => void} [onProgress=null]
* @param {AbortSignal|null} [signal=null]
* @returns {Promise<Blob>} Encoded MP3 audio blob
*/

View file

@ -39,6 +39,21 @@ export interface TagLibMetadata {
isrc?: string;
explicit?: boolean;
lyrics?: string;
upc?: string;
stik?: Mp4Stik;
extra?: Record<string, string>;
}
export enum Mp4Stik {
HomeVideo = 0,
Normal = 1,
Audiobook = 2,
WhackedBookmark = 5,
MusicVideo = 6,
Movie = 9,
ShortFilm = 9,
TVShow = 10,
Booklet = 11,
}
export interface TagLibReadMetadata extends TagLibMetadata {

View file

@ -5,17 +5,18 @@ import { ByteVector } from '!/@dantheman827/taglib-ts/src/byteVector.js';
import { Mp4Tag, Mp4Item } from '!/@dantheman827/taglib-ts/src/mp4/mp4Tag.js';
import { Variant } from '!/@dantheman827/taglib-ts/src/toolkit/variant.js';
import { doTimed, doTimedAsync } from './doTimed';
import type {
_AddMetadataMessage,
_GetMetadataMessage,
AddMetadataMessage,
GetMetadataMessage,
TagLibFileResponse,
TagLibMetadata,
TagLibMetadataResponse,
TagLibReadMetadata,
TagLibWorkerMessage,
TagLibWorkerResponse,
import {
Mp4Stik,
type _AddMetadataMessage,
type _GetMetadataMessage,
type AddMetadataMessage,
type GetMetadataMessage,
type TagLibFileResponse,
type TagLibMetadata,
type TagLibMetadataResponse,
type TagLibReadMetadata,
type TagLibWorkerMessage,
type TagLibWorkerResponse,
} from './taglib.types';
import { File as TagLibFile } from '!/@dantheman827/taglib-ts/src/file.js';
import { FileRef } from '!/@dantheman827/taglib-ts/src/fileRef.js';
@ -52,8 +53,11 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
releaseDate,
copyright,
isrc,
upc,
explicit,
lyrics,
stik = Mp4Stik.Normal,
extra,
returnType = 'uint8array',
} = message;
@ -126,6 +130,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
const mp4Tag = (underlying as Mp4File).tag() as Mp4Tag;
mp4Tag.setItem('xid ', Mp4Item.fromStringList([`:isrc:${isrc}`]));
}
if (upc) props.replace('UPC', [upc]);
if (lyrics) props.replace('LYRICS', [lyrics.replace(/\r/g, '').replace(/\n/g, '\r\n')]);
if (explicit !== undefined) {
@ -138,6 +143,15 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
}
}
if (stik != null && isMp4) {
const mp4Tag = (underlying as Mp4File).tag() as Mp4Tag;
mp4Tag.setItem('stik', Mp4Item.fromByte(stik));
}
for (const [key, value] of Object.entries(extra || {})) {
if (value) props.replace(key, [value]);
}
ref.setProperties(props);
if (cover) {