refactor(downloads): cleanup downloads and add mp4 stik atom
This commit is contained in:
parent
23c5baae5f
commit
80cd8b2f9b
10 changed files with 417 additions and 208 deletions
150
js/api.js
150
js/api.js
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
113
js/container-classes.ts
Normal 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;
|
||||
}
|
||||
252
js/downloads.js
252
js/downloads.js
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
12
js/lyrics.js
12
js/lyrics.js
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue