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 { readableStreamIterator } from './readableStreamIterator.js';
|
||||||
import { HiFiClient, TidalResponse } from './HiFi.ts';
|
import { HiFiClient, TidalResponse } from './HiFi.ts';
|
||||||
import { isIos, isSafari } from './platform-detection.js';
|
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 const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
|
||||||
export { resolveDownloadTotalBytes };
|
export { resolveDownloadTotalBytes };
|
||||||
|
|
@ -206,7 +215,7 @@ export class LosslessAPI {
|
||||||
|
|
||||||
if (track.type && typeof track.type === 'string') {
|
if (track.type && typeof track.type === 'string') {
|
||||||
const lowType = track.type.toLowerCase();
|
const lowType = track.type.toLowerCase();
|
||||||
if (lowType === 'video' || lowType === 'track') {
|
if (lowType.includes('video') || lowType.includes('track')) {
|
||||||
normalized = { ...track, type: lowType };
|
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 };
|
const result = { album, tracks };
|
||||||
|
|
||||||
if (!(response instanceof TidalResponse)) {
|
if (!(response instanceof TidalResponse)) {
|
||||||
|
|
@ -883,6 +902,14 @@ export class LosslessAPI {
|
||||||
// Removed to reduce API load. Playlists can be very large.
|
// Removed to reduce API load. Playlists can be very large.
|
||||||
// tracks = await this.enrichTracksWithAlbumDates(tracks);
|
// 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 };
|
const result = { playlist, tracks };
|
||||||
|
|
||||||
if (!(response instanceof TidalResponse)) {
|
if (!(response instanceof TidalResponse)) {
|
||||||
|
|
@ -911,6 +938,14 @@ export class LosslessAPI {
|
||||||
// Limited to reduce API load
|
// Limited to reduce API load
|
||||||
tracks = await this.enrichTracksWithAlbumDates(tracks, 10);
|
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 = {
|
const mix = {
|
||||||
id: mixData.id,
|
id: mixData.id,
|
||||||
title: mixData.title,
|
title: mixData.title,
|
||||||
|
|
@ -1013,7 +1048,7 @@ export class LosslessAPI {
|
||||||
|
|
||||||
const isTrack = (v) => v?.id && v.duration;
|
const isTrack = (v) => v?.id && v.duration;
|
||||||
const isAlbum = (v) => v?.id && 'numberOfTracks' in v;
|
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) => {
|
const scan = (value, visited) => {
|
||||||
if (!value || typeof value !== 'object' || visited.has(value)) return;
|
if (!value || typeof value !== 'object' || visited.has(value)) return;
|
||||||
|
|
@ -1600,6 +1635,74 @@ export class LosslessAPI {
|
||||||
return streamUrl;
|
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.
|
* 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 { onProgress, track, calculateDashBytes = true } = options;
|
||||||
const prefetchPromises = prefetchMetadataObjects(track, this);
|
const prefetchPromises = prefetchMetadataObjects(track, this);
|
||||||
const isVideo = track?.type === 'video';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Custom FFMPEG formats are not native TIDAL qualities; download LOSSLESS and transcode
|
// Custom FFMPEG formats are not native TIDAL qualities; download LOSSLESS and transcode
|
||||||
const downloadQuality = isCustomFormat(quality) ? 'LOSSLESS' : quality;
|
const downloadQuality = isCustomFormat(quality) ? 'LOSSLESS' : quality;
|
||||||
|
|
||||||
let lookup;
|
const { lookup, enrichedTrack, isVideo } = await this.enrichTrack(track, { downloadQuality });
|
||||||
if (isVideo) {
|
|
||||||
lookup = await this.getVideo(id);
|
|
||||||
} else {
|
|
||||||
lookup = await this.getTrack(id, downloadQuality);
|
|
||||||
}
|
|
||||||
|
|
||||||
let streamUrl;
|
let streamUrl;
|
||||||
let blob;
|
let blob;
|
||||||
|
|
@ -1776,41 +1873,6 @@ export class LosslessAPI {
|
||||||
message: 'Adding metadata...',
|
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'));
|
onProgress?.(new DownloadProgress('Adding metadata'));
|
||||||
try {
|
try {
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { readableStreamIterator } from './readableStreamIterator';
|
||||||
export interface WriterEntry {
|
export interface WriterEntry {
|
||||||
name: string;
|
name: string;
|
||||||
lastModified: Date;
|
lastModified: Date;
|
||||||
input: Blob | string | ArrayBuffer | Uint8Array;
|
input: Blob | File | string | ArrayBuffer | Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal interface for the Neutralino bridge used by ZipNeutralinoWriter */
|
/** 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.
|
* Triggers individual downloads for each file entry, one after another.
|
||||||
*/
|
*/
|
||||||
export class SequentialFileWriter implements IBulkDownloadWriter {
|
class SequentialFileWriter implements IBulkDownloadWriter {
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
|
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
|
||||||
|
|
@ -64,15 +64,20 @@ export class SequentialFileWriter implements IBulkDownloadWriter {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.input instanceof Blob) {
|
if (file.input instanceof Blob || file.input instanceof File) {
|
||||||
triggerDownload(file.input, name);
|
triggerDownload(file.input, name);
|
||||||
} else {
|
} else {
|
||||||
triggerDownload(new Blob([file.input as BlobPart]), name);
|
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.
|
* Streams a ZIP archive to a file via the File System Access API.
|
||||||
* Prompts the user to choose a save location with showSaveFilePicker.
|
* 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
|
//js/downloads.js
|
||||||
|
//@ts-check
|
||||||
import {
|
import {
|
||||||
buildTrackFilename,
|
buildTrackFilename,
|
||||||
sanitizeForFilename,
|
sanitizeForFilename,
|
||||||
|
|
@ -28,6 +29,8 @@ import { DownloadProgress, ProgressMessage, SegmentedDownloadProgress } from './
|
||||||
import { db } from './db.js';
|
import { db } from './db.js';
|
||||||
import { modernSettings } from './ModernSettings.js';
|
import { modernSettings } from './ModernSettings.js';
|
||||||
import { SVG_CLOSE } from './icons.ts';
|
import { SVG_CLOSE } from './icons.ts';
|
||||||
|
import { MusicAPI } from './music-api.js';
|
||||||
|
import { LyricsManager } from './lyrics.js';
|
||||||
|
|
||||||
const downloadTasks = new Map();
|
const downloadTasks = new Map();
|
||||||
const bulkDownloadTasks = new Map();
|
const bulkDownloadTasks = new Map();
|
||||||
|
|
@ -332,7 +335,7 @@ async function downloadTrackBlob(track, quality, api, signal = null, onProgress
|
||||||
return { blob, extension };
|
return { blob, extension };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bulkDownload(
|
async function bulkDownload({
|
||||||
tracks,
|
tracks,
|
||||||
folderName,
|
folderName,
|
||||||
api,
|
api,
|
||||||
|
|
@ -342,8 +345,8 @@ async function bulkDownload(
|
||||||
writer,
|
writer,
|
||||||
coverBlob = null,
|
coverBlob = null,
|
||||||
type = 'playlist',
|
type = 'playlist',
|
||||||
metadata = null
|
metadata = null,
|
||||||
) {
|
}) {
|
||||||
const { abortController } = bulkDownloadTasks.get(notification);
|
const { abortController } = bulkDownloadTasks.get(notification);
|
||||||
const signal = abortController.signal;
|
const signal = abortController.signal;
|
||||||
|
|
||||||
|
|
@ -648,7 +651,7 @@ async function createBulkWriter(folderName) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'individual') {
|
if (method === 'individual') {
|
||||||
return new SequentialFileWriter();
|
return SequentialFileWriter;
|
||||||
}
|
}
|
||||||
// method === 'zip' (or folder picker unavailable as fallback)
|
// method === 'zip' (or folder picker unavailable as fallback)
|
||||||
if (!forceZipBlob && hasFileSystemAccess) {
|
if (!forceZipBlob && hasFileSystemAccess) {
|
||||||
|
|
@ -657,26 +660,27 @@ async function createBulkWriter(folderName) {
|
||||||
return new ZipBlobWriter(`${folderName}.zip`);
|
return new ZipBlobWriter(`${folderName}.zip`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startBulkDownload(
|
async function startBulkDownload({
|
||||||
tracks,
|
tracks,
|
||||||
defaultName,
|
folderName = '',
|
||||||
api,
|
api,
|
||||||
quality,
|
quality,
|
||||||
lyricsManager,
|
lyricsManager = LyricsManager.instance,
|
||||||
type,
|
type,
|
||||||
name,
|
name,
|
||||||
coverBlob = null,
|
coverBlob = null,
|
||||||
metadata = null
|
metadata = null,
|
||||||
) {
|
single = false,
|
||||||
|
}) {
|
||||||
const notification = createBulkDownloadNotification(type, name, tracks.length);
|
const notification = createBulkDownloadNotification(type, name, tracks.length);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const writer = await createBulkWriter(defaultName);
|
const writer = single ? await createSingleTrackFolderWriter() : await createBulkWriter(folderName);
|
||||||
|
|
||||||
if (writer) {
|
if (writer) {
|
||||||
await bulkDownload(
|
await bulkDownload({
|
||||||
tracks,
|
tracks,
|
||||||
defaultName,
|
folderName,
|
||||||
api,
|
api,
|
||||||
quality,
|
quality,
|
||||||
lyricsManager,
|
lyricsManager,
|
||||||
|
|
@ -684,8 +688,8 @@ async function startBulkDownload(
|
||||||
writer,
|
writer,
|
||||||
coverBlob,
|
coverBlob,
|
||||||
type,
|
type,
|
||||||
metadata
|
metadata,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
completeBulkDownload(notification, true);
|
completeBulkDownload(notification, true);
|
||||||
|
|
@ -706,8 +710,16 @@ async function startBulkDownload(
|
||||||
|
|
||||||
export async function downloadTracks(tracks, api, quality, lyricsManager = null) {
|
export async function downloadTracks(tracks, api, quality, lyricsManager = null) {
|
||||||
const folderName = `Queue - ${new Date().toISOString().slice(0, 10)}`;
|
const folderName = `Queue - ${new Date().toISOString().slice(0, 10)}`;
|
||||||
await startBulkDownload(tracks, folderName, api, quality, lyricsManager, 'queue', 'Queue', null, {
|
await startBulkDownload({
|
||||||
title: 'Queue',
|
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);
|
const coverBlob = await getCoverBlob(api, album.cover || album.album?.cover || album.coverId);
|
||||||
await startBulkDownload(
|
await startBulkDownload({
|
||||||
await annotateTracksWithDiscInfo(tracks, api),
|
tracks: await annotateTracksWithDiscInfo(tracks, api),
|
||||||
folderName,
|
folderName,
|
||||||
api,
|
|
||||||
quality,
|
quality,
|
||||||
lyricsManager,
|
type: 'album',
|
||||||
'album',
|
name: album.title,
|
||||||
album.title,
|
|
||||||
coverBlob,
|
coverBlob,
|
||||||
album
|
metadata: album,
|
||||||
);
|
api,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyricsManager = null) {
|
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 representativeTrack = tracks.find((t) => t.album?.cover);
|
||||||
const coverBlob = await getCoverBlob(api, representativeTrack?.album?.cover);
|
const coverBlob = await getCoverBlob(api, representativeTrack?.album?.cover);
|
||||||
await startBulkDownload(
|
await startBulkDownload({
|
||||||
tracks,
|
tracks,
|
||||||
folderName,
|
folderName,
|
||||||
api,
|
|
||||||
quality,
|
quality,
|
||||||
lyricsManager,
|
type: 'playlist',
|
||||||
'playlist',
|
name: playlist.title,
|
||||||
playlist.title,
|
|
||||||
coverBlob,
|
coverBlob,
|
||||||
playlist
|
metadata: playlist,
|
||||||
);
|
api,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadDiscography(artist, selectedReleases, api, quality, lyricsManager = null) {
|
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.bulkType = type;
|
||||||
notifEl.dataset.bulkName = name;
|
notifEl.dataset.bulkName = name;
|
||||||
|
|
||||||
const typeLabel =
|
const typeLabel = (() => {
|
||||||
type === 'album'
|
switch (type) {
|
||||||
? 'Album'
|
case 'album':
|
||||||
: type === 'playlist'
|
return 'Album';
|
||||||
? 'Playlist'
|
case 'playlist':
|
||||||
: type === 'liked'
|
return 'Playlist';
|
||||||
? 'Liked Tracks'
|
case 'liked':
|
||||||
: type === 'queue'
|
return 'Liked Tracks';
|
||||||
? 'Queue'
|
case 'queue':
|
||||||
: 'Discography';
|
return 'Queue';
|
||||||
|
case 'discography':
|
||||||
|
return 'Discography';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
notifEl.innerHTML = `
|
notifEl.innerHTML = `
|
||||||
<div style="display: flex; align-items: start; gap: 0.75rem;">
|
<div style="display: flex; align-items: start; gap: 0.75rem;">
|
||||||
|
|
@ -1024,53 +1040,7 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let enrichedTrack = {
|
const { enrichedTrack } = await api.tidalAPI.enrichTrack(track, { downloadQuality: quality });
|
||||||
...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 filename = buildTrackFilename(enrichedTrack, quality);
|
const filename = buildTrackFilename(enrichedTrack, quality);
|
||||||
|
|
||||||
const controller = abortController || new AbortController();
|
const controller = abortController || new AbortController();
|
||||||
|
|
@ -1079,74 +1049,67 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
|
||||||
try {
|
try {
|
||||||
// Resolve the folder writer before registering the download task so that
|
// Resolve the folder writer before registering the download task so that
|
||||||
// any permission prompt (requestPermission) shows before the UI task appears.
|
// 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);
|
addDownloadTask(track.id, enrichedTrack, filename, api, controller);
|
||||||
|
|
||||||
// Try to write directly to the configured folder when the feature is enabled.
|
// Download the blob (metadata already applied inside downloadTrack)
|
||||||
if (folderWriter) {
|
const blob = await api.downloadTrack(track.id, quality, filename, {
|
||||||
// Download the blob (metadata already applied inside downloadTrack)
|
signal: controller.signal,
|
||||||
const blob = await api.downloadTrack(track.id, quality, filename, {
|
track: enrichedTrack,
|
||||||
signal: controller.signal,
|
onProgress: (progress) => {
|
||||||
track: enrichedTrack,
|
updateDownloadProgress(track.id, progress);
|
||||||
onProgress: (progress) => {
|
},
|
||||||
updateDownloadProgress(track.id, progress);
|
calculateDashBytes: true,
|
||||||
},
|
triggerDownload: false,
|
||||||
calculateDashBytes: true,
|
});
|
||||||
triggerDownload: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentExtension = filename.split('.').pop()?.toLowerCase();
|
const finalFilename = buildTrackFilename(track, quality, await getExtensionFromBlob(blob))
|
||||||
const finalFilename = buildTrackFilename(track, quality, await getExtensionFromBlob(blob))
|
.split('/')
|
||||||
.split('/')
|
.pop();
|
||||||
.pop();
|
|
||||||
|
|
||||||
// Compute a subfolder path using the same template as bulk downloads so
|
// 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.
|
// the track lands in e.g. "Album Title - Artist/" instead of the folder root.
|
||||||
const releaseDateStr =
|
const releaseDateStr =
|
||||||
enrichedTrack.album?.releaseDate ||
|
enrichedTrack.album?.releaseDate ||
|
||||||
(enrichedTrack.streamStartDate ? enrichedTrack.streamStartDate.split('T')[0] : '');
|
(enrichedTrack.streamStartDate ? enrichedTrack.streamStartDate.split('T')[0] : '');
|
||||||
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
||||||
const releaseYear = releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate.getFullYear() : '';
|
const releaseYear = releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate.getFullYear() : '';
|
||||||
const subFolder = formatPathTemplate(modernSettings.folderTemplate, {
|
const subFolder = formatPathTemplate(modernSettings.folderTemplate, {
|
||||||
albumTitle: enrichedTrack.album?.title,
|
albumTitle: enrichedTrack.album?.title,
|
||||||
albumArtist: enrichedTrack.album?.artist?.name || enrichedTrack.artist?.name,
|
albumArtist: enrichedTrack.album?.artist?.name || enrichedTrack.artist?.name,
|
||||||
year: releaseYear,
|
year: releaseYear,
|
||||||
});
|
});
|
||||||
const entryName = subFolder ? `${subFolder}/${finalFilename}` : finalFilename;
|
const entryName = subFolder ? `${subFolder}/${finalFilename}` : finalFilename;
|
||||||
|
|
||||||
// Write to folder using IBulkDownloadWriter.write() via singleWriterEntry().
|
// Write to folder using IBulkDownloadWriter.write() via singleWriterEntry().
|
||||||
await folderWriter.write(singleWriterEntry({ name: entryName, lastModified: new Date(), input: blob }));
|
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);
|
|
||||||
|
|
||||||
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
||||||
try {
|
try {
|
||||||
const lyricsData = await lyricsManager.fetchLyrics(track.id, track);
|
const lyricsData = await lyricsManager.fetchLyrics(track.id, track);
|
||||||
if (lyricsData) {
|
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 {
|
} catch {
|
||||||
console.log('Could not download lyrics for track');
|
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) {
|
} catch (error) {
|
||||||
if (error.name !== 'AbortError') {
|
if (error.name !== 'AbortError') {
|
||||||
const errorMsg =
|
const errorMsg =
|
||||||
|
|
@ -1160,5 +1123,12 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
|
||||||
|
|
||||||
export async function downloadLikedTracks(tracks, api, quality, lyricsManager = null) {
|
export async function downloadLikedTracks(tracks, api, quality, lyricsManager = null) {
|
||||||
const folderName = `Liked Tracks - ${new Date().toISOString().slice(0, 10)}`;
|
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 coreWasm from '!/@ffmpeg/core/dist/esm/ffmpeg-core.wasm?blob-url';
|
||||||
import { FfmpegProgress } from './ffmpeg.types';
|
import { FfmpegProgress } from './ffmpeg.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('./ffmpeg.types.ts').FfmpegProgress} FfmpegProgress
|
||||||
|
*/
|
||||||
|
|
||||||
class FfmpegError extends Error {
|
class FfmpegError extends Error {
|
||||||
constructor(message) {
|
constructor(message) {
|
||||||
super(message);
|
super(message);
|
||||||
|
|
@ -31,7 +35,7 @@ export function loadFfmpeg() {
|
||||||
* @param {string[]} args
|
* @param {string[]} args
|
||||||
* @param {string} outputName
|
* @param {string} outputName
|
||||||
* @param {string} outputMime
|
* @param {string} outputMime
|
||||||
* @param {(progress: import('./ffmpeg.types.ts').FfmpegProgress) => void} onProgress
|
* @param {(progress: FfmpegProgress) => void} onProgress
|
||||||
* @param {AbortSignal|null} signal
|
* @param {AbortSignal|null} signal
|
||||||
* @param {Array<{name: string, data: ArrayBuffer | Uint8Array}>} extraFiles
|
* @param {Array<{name: string, data: ArrayBuffer | Uint8Array}>} extraFiles
|
||||||
* @returns {Promise<Blob>} Encoded audio blob
|
* @returns {Promise<Blob>} Encoded audio blob
|
||||||
|
|
@ -126,7 +130,7 @@ async function ffmpegWorker(
|
||||||
* @param {string[]} [args=[]] - FFmpeg command-line arguments
|
* @param {string[]} [args=[]] - FFmpeg command-line arguments
|
||||||
* @param {string} [outputName='output'] - Name of the output file
|
* @param {string} [outputName='output'] - Name of the output file
|
||||||
* @param {string} [outputMime='application/octet-stream'] - MIME type of the output
|
* @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 {AbortSignal|null} [signal=null] - Optional abort signal to cancel encoding
|
||||||
* @param {Array} [extraFiles=[]] - Additional files to provide to FFmpeg
|
* @param {Array} [extraFiles=[]] - Additional files to provide to FFmpeg
|
||||||
* @returns {Promise<Blob>} Encoded audio blob
|
* @returns {Promise<Blob>} Encoded audio blob
|
||||||
|
|
|
||||||
12
js/lyrics.js
12
js/lyrics.js
|
|
@ -489,18 +489,24 @@ export class LyricsManager {
|
||||||
return lrc;
|
return lrc;
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadLRC(lyricsData, track) {
|
getLRC(lyricsData, track) {
|
||||||
const lrcContent = this.generateLRCContent(lyricsData, track);
|
const lrcContent = this.generateLRCContent(lyricsData, track);
|
||||||
if (!lrcContent) {
|
if (!lrcContent) {
|
||||||
alert('No synced lyrics available for this track');
|
alert('No synced lyrics available for this track');
|
||||||
return;
|
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 url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = buildTrackFilename(track, 'LOSSLESS').replace(/\.flac$/, '.lrc');
|
a.download = blob.name;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import { getCoverBlob, getTrackTitle, getFullArtistString, getMimeType, getTrackCoverId } from './utils.js';
|
import { getCoverBlob, getTrackTitle, getFullArtistString, getMimeType, getTrackCoverId } from './utils.js';
|
||||||
import { addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts';
|
import { addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts';
|
||||||
import { doTimed, doTimedAsync } from './doTimed.ts';
|
|
||||||
import { LyricsManager } from './lyrics.js';
|
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) {
|
export function prefetchMetadataObjects(track, api, coverBlob = null) {
|
||||||
const coverId = getTrackCoverId(track);
|
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)
|
* Adds metadata tags to audio files (FLAC, M4A or MP3)
|
||||||
* @param {Blob} audioBlob - The audio file blob
|
* @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 {Object} api - API instance for fetching album art
|
||||||
* @param {string} quality - Audio quality
|
* @param {string} quality - Audio quality
|
||||||
* @returns {Promise<Blob>} - Audio blob with embedded metadata
|
* @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;
|
const { coverFetch, lyricsFetch } = prefetchPromises;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {import("./taglib.worker.ts").TagLibMetadata}
|
* @type {TagLibMetadata}
|
||||||
*/
|
*/
|
||||||
const data = {};
|
const data = {};
|
||||||
|
|
||||||
|
|
@ -42,7 +48,17 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet
|
||||||
data.totalDiscs = track.album?.totalDiscs;
|
data.totalDiscs = track.album?.totalDiscs;
|
||||||
data.copyright = track.copyright;
|
data.copyright = track.copyright;
|
||||||
data.isrc = track.isrc;
|
data.isrc = track.isrc;
|
||||||
|
data.upc = track.album?.upc;
|
||||||
data.explicit = Boolean(track.explicit);
|
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) {
|
if (track.bpm != null) {
|
||||||
const bpm = Number(track.bpm);
|
const bpm = Number(track.bpm);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { ffmpeg } from './ffmpeg';
|
import { ffmpeg } from './ffmpeg';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('./ffmpeg.types.ts').FfmpegProgress} FfmpegProgress
|
||||||
|
*/
|
||||||
|
|
||||||
class MP3EncodingError extends Error {
|
class MP3EncodingError extends Error {
|
||||||
constructor(message) {
|
constructor(message) {
|
||||||
super(message);
|
super(message);
|
||||||
|
|
@ -11,7 +15,7 @@ class MP3EncodingError extends Error {
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {Blob} audioBlob
|
* @param {Blob} audioBlob
|
||||||
* @param {(progress: import('./ffmpeg.types.ts').FfmpegProgress) => void} [onProgress=null]
|
* @param {(progress: FfmpegProgress) => void} [onProgress=null]
|
||||||
* @param {AbortSignal|null} [signal=null]
|
* @param {AbortSignal|null} [signal=null]
|
||||||
* @returns {Promise<Blob>} Encoded MP3 audio blob
|
* @returns {Promise<Blob>} Encoded MP3 audio blob
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,21 @@ export interface TagLibMetadata {
|
||||||
isrc?: string;
|
isrc?: string;
|
||||||
explicit?: boolean;
|
explicit?: boolean;
|
||||||
lyrics?: string;
|
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 {
|
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 { Mp4Tag, Mp4Item } from '!/@dantheman827/taglib-ts/src/mp4/mp4Tag.js';
|
||||||
import { Variant } from '!/@dantheman827/taglib-ts/src/toolkit/variant.js';
|
import { Variant } from '!/@dantheman827/taglib-ts/src/toolkit/variant.js';
|
||||||
import { doTimed, doTimedAsync } from './doTimed';
|
import { doTimed, doTimedAsync } from './doTimed';
|
||||||
import type {
|
import {
|
||||||
_AddMetadataMessage,
|
Mp4Stik,
|
||||||
_GetMetadataMessage,
|
type _AddMetadataMessage,
|
||||||
AddMetadataMessage,
|
type _GetMetadataMessage,
|
||||||
GetMetadataMessage,
|
type AddMetadataMessage,
|
||||||
TagLibFileResponse,
|
type GetMetadataMessage,
|
||||||
TagLibMetadata,
|
type TagLibFileResponse,
|
||||||
TagLibMetadataResponse,
|
type TagLibMetadata,
|
||||||
TagLibReadMetadata,
|
type TagLibMetadataResponse,
|
||||||
TagLibWorkerMessage,
|
type TagLibReadMetadata,
|
||||||
TagLibWorkerResponse,
|
type TagLibWorkerMessage,
|
||||||
|
type TagLibWorkerResponse,
|
||||||
} from './taglib.types';
|
} from './taglib.types';
|
||||||
import { File as TagLibFile } from '!/@dantheman827/taglib-ts/src/file.js';
|
import { File as TagLibFile } from '!/@dantheman827/taglib-ts/src/file.js';
|
||||||
import { FileRef } from '!/@dantheman827/taglib-ts/src/fileRef.js';
|
import { FileRef } from '!/@dantheman827/taglib-ts/src/fileRef.js';
|
||||||
|
|
@ -52,8 +53,11 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
|
||||||
releaseDate,
|
releaseDate,
|
||||||
copyright,
|
copyright,
|
||||||
isrc,
|
isrc,
|
||||||
|
upc,
|
||||||
explicit,
|
explicit,
|
||||||
lyrics,
|
lyrics,
|
||||||
|
stik = Mp4Stik.Normal,
|
||||||
|
extra,
|
||||||
returnType = 'uint8array',
|
returnType = 'uint8array',
|
||||||
} = message;
|
} = message;
|
||||||
|
|
||||||
|
|
@ -126,6 +130,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<
|
||||||
const mp4Tag = (underlying as Mp4File).tag() as Mp4Tag;
|
const mp4Tag = (underlying as Mp4File).tag() as Mp4Tag;
|
||||||
mp4Tag.setItem('xid ', Mp4Item.fromStringList([`:isrc:${isrc}`]));
|
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 (lyrics) props.replace('LYRICS', [lyrics.replace(/\r/g, '').replace(/\n/g, '\r\n')]);
|
||||||
|
|
||||||
if (explicit !== undefined) {
|
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);
|
ref.setProperties(props);
|
||||||
|
|
||||||
if (cover) {
|
if (cover) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue