Merge pull request #307 from DanTheMan827/progress-improvements
Fix taglib worker initialization and enhance download progress handling
This commit is contained in:
commit
39b5090a67
12 changed files with 335 additions and 376 deletions
86
js/api.js
86
js/api.js
|
|
@ -7,17 +7,19 @@ import {
|
||||||
getExtensionFromBlob,
|
getExtensionFromBlob,
|
||||||
getTrackTitle,
|
getTrackTitle,
|
||||||
getFullArtistString,
|
getFullArtistString,
|
||||||
|
getTrackDiscNumber,
|
||||||
getMimeType,
|
getMimeType,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
import { trackDateSettings } from './storage.js';
|
import { trackDateSettings } from './storage.js';
|
||||||
import { APICache } from './cache.js';
|
import { APICache } from './cache.js';
|
||||||
import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
|
import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
|
||||||
import { DashDownloader } from './dash-downloader.js';
|
import { DashDownloader } from './dash-downloader.ts';
|
||||||
import { HlsDownloader } from './hls-downloader.js';
|
import { HlsDownloader } from './hls-downloader.js';
|
||||||
import { MP3EncodingError } from './mp3-encoder.js';
|
import { MP3EncodingError } from './mp3-encoder.js';
|
||||||
import { loadFfmpeg, FfmpegError } from './ffmpeg.js';
|
import { loadFfmpeg, FfmpegError } from './ffmpeg.js';
|
||||||
import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts';
|
import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts';
|
||||||
import { isCustomFormat } from './ffmpegFormats.ts';
|
import { isCustomFormat } from './ffmpegFormats.ts';
|
||||||
|
import { DownloadProgress } from './progressEvents.js';
|
||||||
|
|
||||||
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
|
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
|
||||||
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
|
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
|
||||||
|
|
@ -1288,11 +1290,36 @@ export class LosslessAPI {
|
||||||
return streamUrl;
|
return streamUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads a track or video from TIDAL in the specified quality.
|
||||||
|
*
|
||||||
|
* Handles multiple stream types (DASH, HLS, and direct HTTP), applies post-processing
|
||||||
|
* for audio tracks, adds metadata, and optionally triggers a browser download.
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {string} id - The TIDAL track or video ID
|
||||||
|
* @param {string} [quality='HI_RES_LOSSLESS'] - The desired audio quality (e.g., 'HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'NORMAL').
|
||||||
|
* Custom FFMPEG formats are transcoded from LOSSLESS.
|
||||||
|
* @param {string} filename - The filename to save the downloaded content as
|
||||||
|
* @param {Object} [options={}] - Additional download options
|
||||||
|
* @param {Function} [options.onProgress] - Callback function for progress updates with signature:
|
||||||
|
* `(progressEvent) => void`
|
||||||
|
* @param {Object} [options.track] - Track metadata object to attach to the audio file
|
||||||
|
* @param {boolean} [options.calculateDashBytes=true] - Whether to calculate total bytes for DASH streams
|
||||||
|
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the download
|
||||||
|
* @param {boolean} [options.triggerDownload=true] - Whether to trigger browser download after completion
|
||||||
|
*
|
||||||
|
* @returns {Promise<Blob>} The downloaded content as a Blob object
|
||||||
|
*
|
||||||
|
* @throws {Error} If stream URL cannot be resolved, manifest is missing, or download fails
|
||||||
|
* @throws {AbortError} If the download is aborted via the signal
|
||||||
|
* @throws {MP3EncodingError|FfmpegError} If audio transcoding fails
|
||||||
|
*/
|
||||||
async downloadTrack(id, quality = 'HI_RES_LOSSLESS', filename, options = {}) {
|
async downloadTrack(id, quality = 'HI_RES_LOSSLESS', filename, options = {}) {
|
||||||
// Load ffmpeg in the background.
|
// Load ffmpeg in the background.
|
||||||
loadFfmpeg().catch(console.error);
|
loadFfmpeg().catch(console.error);
|
||||||
|
|
||||||
const { onProgress, track } = options;
|
const { onProgress, track, calculateDashBytes = true } = options;
|
||||||
const prefetchPromises = prefetchMetadataObjects(track, this);
|
const prefetchPromises = prefetchMetadataObjects(track, this);
|
||||||
const isVideo = track?.type === 'video';
|
const isVideo = track?.type === 'video';
|
||||||
|
|
||||||
|
|
@ -1345,7 +1372,8 @@ export class LosslessAPI {
|
||||||
const downloader = new DashDownloader();
|
const downloader = new DashDownloader();
|
||||||
blob = await downloader.downloadDashStream(streamUrl, {
|
blob = await downloader.downloadDashStream(streamUrl, {
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
onProgress: options.onProgress,
|
onProgress,
|
||||||
|
calculateDashBytes: calculateDashBytes ?? true,
|
||||||
});
|
});
|
||||||
} catch (dashError) {
|
} catch (dashError) {
|
||||||
console.error('DASH download failed:', dashError);
|
console.error('DASH download failed:', dashError);
|
||||||
|
|
@ -1363,7 +1391,7 @@ export class LosslessAPI {
|
||||||
const downloader = new HlsDownloader();
|
const downloader = new HlsDownloader();
|
||||||
blob = await downloader.downloadHlsStream(streamUrl, {
|
blob = await downloader.downloadHlsStream(streamUrl, {
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
onProgress: options.onProgress,
|
onProgress,
|
||||||
});
|
});
|
||||||
} catch (hlsError) {
|
} catch (hlsError) {
|
||||||
console.error('HLS download failed:', hlsError);
|
console.error('HLS download failed:', hlsError);
|
||||||
|
|
@ -1384,7 +1412,7 @@ export class LosslessAPI {
|
||||||
|
|
||||||
let receivedBytes = 0;
|
let receivedBytes = 0;
|
||||||
|
|
||||||
if (response.body && onProgress) {
|
if (response.body) {
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
|
|
||||||
|
|
@ -1396,25 +1424,16 @@ export class LosslessAPI {
|
||||||
chunks.push(value);
|
chunks.push(value);
|
||||||
receivedBytes += value.byteLength;
|
receivedBytes += value.byteLength;
|
||||||
|
|
||||||
onProgress({
|
onProgress?.(new DownloadProgress(receivedBytes, totalBytes || undefined));
|
||||||
stage: 'downloading',
|
|
||||||
receivedBytes,
|
|
||||||
totalBytes: totalBytes || undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultMime = isVideo ? 'video/mp4' : 'audio/flac';
|
const defaultMime = isVideo ? 'video/mp4' : 'audio/flac';
|
||||||
blob = new Blob(chunks, { type: response.headers.get('Content-Type') || defaultMime });
|
blob = new Blob(chunks, { type: response.headers.get('Content-Type') || defaultMime });
|
||||||
} else {
|
} else {
|
||||||
|
onProgress?.(new DownloadProgress(0, undefined));
|
||||||
blob = await response.blob();
|
blob = await response.blob();
|
||||||
if (onProgress) {
|
onProgress?.(new DownloadProgress(blob.size, blob.size));
|
||||||
onProgress({
|
|
||||||
stage: 'downloading',
|
|
||||||
receivedBytes: blob.size,
|
|
||||||
totalBytes: blob.size,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1423,12 +1442,10 @@ export class LosslessAPI {
|
||||||
|
|
||||||
// Add metadata if track information is provided
|
// Add metadata if track information is provided
|
||||||
if (track) {
|
if (track) {
|
||||||
if (onProgress) {
|
onProgress?.({
|
||||||
onProgress({
|
|
||||||
stage: 'processing',
|
stage: 'processing',
|
||||||
message: 'Adding metadata...',
|
message: 'Adding metadata...',
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const enrichedTrack = { ...track };
|
const enrichedTrack = { ...track };
|
||||||
if (lookup.info) {
|
if (lookup.info) {
|
||||||
|
|
@ -1445,38 +1462,17 @@ export class LosslessAPI {
|
||||||
(track.album?.totalDiscs == null || track.album?.numberOfTracksOnDisc == null)
|
(track.album?.totalDiscs == null || track.album?.numberOfTracksOnDisc == null)
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Broad disc-field resolver — mirrors getExplicitTrackDiscNumber in downloads.js
|
|
||||||
const resolveDiscNumber = (t) => {
|
|
||||||
const candidates = [
|
|
||||||
t.volumeNumber,
|
|
||||||
t.discNumber,
|
|
||||||
t.mediaNumber,
|
|
||||||
t.media_number,
|
|
||||||
t.volume,
|
|
||||||
t.disc,
|
|
||||||
t.disc_no,
|
|
||||||
t.discNo,
|
|
||||||
t.disc_number,
|
|
||||||
t.mediaMetadata?.discNumber,
|
|
||||||
];
|
|
||||||
for (const c of candidates) {
|
|
||||||
const parsed = parseInt(c, 10);
|
|
||||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const albumData = await this.getAlbum(track.album.id);
|
const albumData = await this.getAlbum(track.album.id);
|
||||||
if (albumData.tracks?.length > 0) {
|
if (albumData.tracks?.length > 0) {
|
||||||
const discTrackCounts = new Map();
|
const discTrackCounts = new Map();
|
||||||
let maxDiscNumber = 0;
|
let maxDiscNumber = 0;
|
||||||
for (const t of albumData.tracks) {
|
for (const t of albumData.tracks) {
|
||||||
const dn = resolveDiscNumber(t);
|
const dn = getTrackDiscNumber(t);
|
||||||
discTrackCounts.set(dn, (discTrackCounts.get(dn) || 0) + 1);
|
discTrackCounts.set(dn, (discTrackCounts.get(dn) || 0) + 1);
|
||||||
if (dn > maxDiscNumber) maxDiscNumber = dn;
|
if (dn > maxDiscNumber) maxDiscNumber = dn;
|
||||||
}
|
}
|
||||||
const totalDiscs = maxDiscNumber || 1;
|
const totalDiscs = maxDiscNumber || 1;
|
||||||
const discNumber = resolveDiscNumber(track);
|
const discNumber = getTrackDiscNumber(track);
|
||||||
enrichedTrack.album = {
|
enrichedTrack.album = {
|
||||||
...(enrichedTrack.album || {}),
|
...(enrichedTrack.album || {}),
|
||||||
totalDiscs: track.album?.totalDiscs ?? totalDiscs,
|
totalDiscs: track.album?.totalDiscs ?? totalDiscs,
|
||||||
|
|
@ -1489,10 +1485,12 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onProgress?.(new DownloadProgress('Adding metadata'));
|
||||||
blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises);
|
blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.triggerDownload ?? true) {
|
||||||
// Detect actual format and fix filename extension if needed
|
// Detect actual format and fix filename extension if needed
|
||||||
const detectedExtension = await getExtensionFromBlob(blob);
|
const detectedExtension = await getExtensionFromBlob(blob);
|
||||||
let finalFilename = filename;
|
let finalFilename = filename;
|
||||||
|
|
@ -1504,6 +1502,8 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerDownload(blob, finalFilename);
|
triggerDownload(blob, finalFilename);
|
||||||
|
}
|
||||||
|
|
||||||
return blob;
|
return blob;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,35 @@ export interface IBulkDownloadWriter {
|
||||||
write(files: AsyncIterable<WriterEntry>): Promise<void>;
|
write(files: AsyncIterable<WriterEntry>): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers individual downloads for each file entry, one after another.
|
||||||
|
*/
|
||||||
|
export class SequentialFileWriter implements IBulkDownloadWriter {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
|
||||||
|
for await (const file of files) {
|
||||||
|
const name = file.name?.split('/').pop();
|
||||||
|
const ext = name?.split('.').pop().toLowerCase();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
console.warn('No name for file entry.', file);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['m3u', 'm3u8', 'cue', 'jpg', 'png', 'nfo', 'json'].includes(ext)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.input instanceof Blob) {
|
||||||
|
triggerDownload(file.input, name);
|
||||||
|
} else {
|
||||||
|
triggerDownload(new Blob([file.input as BlobPart]), name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,54 @@
|
||||||
|
import { AbortError } from './errorTypes';
|
||||||
|
import { SegmentedDownloadProgress } from './progressEvents';
|
||||||
|
|
||||||
|
export interface DashDownloadOptions {
|
||||||
|
onProgress?: MonochromeProgressListener<SegmentedDownloadProgress>;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
calculateDashBytes?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashSegment {
|
||||||
|
number: number;
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashManifest {
|
||||||
|
baseUrl: string;
|
||||||
|
initialization: string | null;
|
||||||
|
media: string | null;
|
||||||
|
segments: DashSegment[];
|
||||||
|
repId: string | null;
|
||||||
|
mimeType: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class DashDownloader {
|
export class DashDownloader {
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
async downloadDashStream(manifestBlobUrl, options = {}) {
|
async getTotalSize(urls: string[], signal?: AbortSignal): Promise<number | undefined> {
|
||||||
const { onProgress, signal } = options;
|
try {
|
||||||
|
let totalSize = 0;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
urls.map(async (url) => {
|
||||||
|
const result = await fetch(url, { method: 'HEAD', signal });
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
const contentLength = result.headers.get('Content-Length');
|
||||||
|
if (contentLength) totalSize += parseInt(contentLength, 10);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Failed to fetch segment HEAD: ${result.status}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return totalSize;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadDashStream(manifestBlobUrl: string, options: DashDownloadOptions = {}): Promise<Blob> {
|
||||||
|
const { onProgress, signal, calculateDashBytes = true } = options;
|
||||||
|
|
||||||
// 1. Fetch and Parse Manifest
|
// 1. Fetch and Parse Manifest
|
||||||
const response = await fetch(manifestBlobUrl);
|
const response = await fetch(manifestBlobUrl);
|
||||||
|
|
@ -18,24 +64,30 @@ export class DashDownloader {
|
||||||
const mimeType = manifest.mimeType || 'audio/mp4';
|
const mimeType = manifest.mimeType || 'audio/mp4';
|
||||||
|
|
||||||
// 3. Download Segments
|
// 3. Download Segments
|
||||||
const chunks = [];
|
const chunks: ArrayBuffer[] = [];
|
||||||
let downloadedBytes = 0;
|
let downloadedBytes = 0;
|
||||||
// Estimate total size? Hard to know exactly without Content-Length of each.
|
|
||||||
// We can just track progress by segment count.
|
|
||||||
const totalSegments = urls.length;
|
const totalSegments = urls.length;
|
||||||
|
const totalSize = calculateDashBytes ? await this.getTotalSize(urls, signal) : undefined;
|
||||||
|
|
||||||
for (let i = 0; i < urls.length; i++) {
|
for (let i = 0; i < urls.length; i++) {
|
||||||
if (signal?.aborted) throw new Error('AbortError');
|
if (signal?.aborted) throw new AbortError();
|
||||||
|
|
||||||
|
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, totalSize ?? undefined, i, totalSegments));
|
||||||
|
|
||||||
const url = urls[i];
|
const url = urls[i];
|
||||||
const segmentResponse = await fetch(url, { signal });
|
const segmentResponse = await fetch(url, { signal });
|
||||||
|
|
||||||
if (!segmentResponse.ok) {
|
if (!segmentResponse.ok) {
|
||||||
// Retry once?
|
|
||||||
console.warn(`Failed to fetch segment ${i}, retrying...`);
|
console.warn(`Failed to fetch segment ${i}, retrying...`);
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
|
||||||
const retryResponse = await fetch(url, { signal });
|
const retryResponse = await fetch(url, { signal });
|
||||||
if (!retryResponse.ok) throw new Error(`Failed to fetch segment ${i}: ${retryResponse.status}`);
|
|
||||||
|
if (!retryResponse.ok) {
|
||||||
|
throw new Error(`Failed to fetch segment ${i}: ${retryResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
const chunk = await retryResponse.arrayBuffer();
|
const chunk = await retryResponse.arrayBuffer();
|
||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
downloadedBytes += chunk.byteLength;
|
downloadedBytes += chunk.byteLength;
|
||||||
|
|
@ -45,22 +97,14 @@ export class DashDownloader {
|
||||||
downloadedBytes += chunk.byteLength;
|
downloadedBytes += chunk.byteLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onProgress) {
|
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, totalSize ?? undefined, i + 1, totalSegments));
|
||||||
onProgress({
|
|
||||||
stage: 'downloading',
|
|
||||||
receivedBytes: downloadedBytes, // accurate byte count
|
|
||||||
totalBytes: undefined, // Unknown total
|
|
||||||
currentSegment: i + 1,
|
|
||||||
totalSegments: totalSegments,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Concatenate
|
// 4. Concatenate
|
||||||
return new Blob(chunks, { type: mimeType });
|
return new Blob(chunks, { type: mimeType });
|
||||||
}
|
}
|
||||||
|
|
||||||
parseManifest(manifestText) {
|
parseManifest(manifestText: string): DashManifest {
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const xml = parser.parseFromString(manifestText, 'text/xml');
|
const xml = parser.parseFromString(manifestText, 'text/xml');
|
||||||
|
|
||||||
|
|
@ -70,25 +114,22 @@ export class DashDownloader {
|
||||||
const period = mpd.querySelector('Period');
|
const period = mpd.querySelector('Period');
|
||||||
if (!period) throw new Error('Invalid DASH manifest: No Period tag');
|
if (!period) throw new Error('Invalid DASH manifest: No Period tag');
|
||||||
|
|
||||||
// Prefer highest bandwidth audio adaptation set
|
|
||||||
const adaptationSets = Array.from(period.querySelectorAll('AdaptationSet'));
|
const adaptationSets = Array.from(period.querySelectorAll('AdaptationSet'));
|
||||||
|
|
||||||
adaptationSets.sort((a, b) => {
|
adaptationSets.sort((a, b) => {
|
||||||
const getMaxBandwidth = (set) => {
|
const getMaxBandwidth = (set: Element) => {
|
||||||
const reps = Array.from(set.querySelectorAll('Representation'));
|
const reps = Array.from(set.querySelectorAll('Representation'));
|
||||||
return reps.length ? Math.max(...reps.map((r) => parseInt(r.getAttribute('bandwidth') || '0', 10))) : 0;
|
return reps.length ? Math.max(...reps.map((r) => parseInt(r.getAttribute('bandwidth') || '0', 10))) : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
return getMaxBandwidth(b) - getMaxBandwidth(a);
|
return getMaxBandwidth(b) - getMaxBandwidth(a);
|
||||||
});
|
});
|
||||||
|
|
||||||
let audioSet = adaptationSets.find((as) => as.getAttribute('mimeType')?.startsWith('audio'));
|
let audioSet = adaptationSets.find((as) => as.getAttribute('mimeType')?.startsWith('audio')) ?? null;
|
||||||
|
|
||||||
// Fallback: look for any adaptation set if mimeType is missing (rare)
|
|
||||||
if (!audioSet && adaptationSets.length > 0) audioSet = adaptationSets[0];
|
if (!audioSet && adaptationSets.length > 0) audioSet = adaptationSets[0];
|
||||||
if (!audioSet) throw new Error('No AdaptationSet found');
|
if (!audioSet) throw new Error('No AdaptationSet found');
|
||||||
|
|
||||||
// Find Representation
|
|
||||||
// Get all representations and sort by bandwidth descending
|
|
||||||
const representations = Array.from(audioSet.querySelectorAll('Representation')).sort((a, b) => {
|
const representations = Array.from(audioSet.querySelectorAll('Representation')).sort((a, b) => {
|
||||||
const bwA = parseInt(a.getAttribute('bandwidth') || '0');
|
const bwA = parseInt(a.getAttribute('bandwidth') || '0');
|
||||||
const bwB = parseInt(b.getAttribute('bandwidth') || '0');
|
const bwB = parseInt(b.getAttribute('bandwidth') || '0');
|
||||||
|
|
@ -96,55 +137,47 @@ export class DashDownloader {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (representations.length === 0) throw new Error('No Representation found');
|
if (representations.length === 0) throw new Error('No Representation found');
|
||||||
|
|
||||||
const rep = representations[0];
|
const rep = representations[0];
|
||||||
const repId = rep.getAttribute('id');
|
const repId = rep.getAttribute('id');
|
||||||
|
|
||||||
// Find SegmentTemplate
|
|
||||||
// Can be in Representation or AdaptationSet
|
|
||||||
const segmentTemplate = rep.querySelector('SegmentTemplate') || audioSet.querySelector('SegmentTemplate');
|
const segmentTemplate = rep.querySelector('SegmentTemplate') || audioSet.querySelector('SegmentTemplate');
|
||||||
|
|
||||||
if (!segmentTemplate) throw new Error('No SegmentTemplate found');
|
if (!segmentTemplate) throw new Error('No SegmentTemplate found');
|
||||||
|
|
||||||
const initialization = segmentTemplate.getAttribute('initialization');
|
const initialization = segmentTemplate.getAttribute('initialization');
|
||||||
const media = segmentTemplate.getAttribute('media');
|
const media = segmentTemplate.getAttribute('media');
|
||||||
const startNumber = parseInt(segmentTemplate.getAttribute('startNumber') || '1', 10);
|
const startNumber = parseInt(segmentTemplate.getAttribute('startNumber') || '1', 10);
|
||||||
|
|
||||||
// BaseURL
|
|
||||||
// Can be at MPD, Period, AdaptationSet, or Representation level.
|
|
||||||
// We strictly need to find the "deepest" one or combine them?
|
|
||||||
// Usually simpler manifests have it at one level.
|
|
||||||
// Let's resolve closest BaseURL.
|
|
||||||
const baseUrlTag =
|
const baseUrlTag =
|
||||||
rep.querySelector('BaseURL') ||
|
rep.querySelector('BaseURL') ||
|
||||||
audioSet.querySelector('BaseURL') ||
|
audioSet.querySelector('BaseURL') ||
|
||||||
period.querySelector('BaseURL') ||
|
period.querySelector('BaseURL') ||
|
||||||
mpd.querySelector('BaseURL');
|
mpd.querySelector('BaseURL');
|
||||||
const baseUrl = baseUrlTag ? baseUrlTag.textContent.trim() : '';
|
|
||||||
|
|
||||||
// SegmentTimeline
|
const baseUrl = baseUrlTag?.textContent?.trim() || '';
|
||||||
|
|
||||||
const segmentTimeline = segmentTemplate.querySelector('SegmentTimeline');
|
const segmentTimeline = segmentTemplate.querySelector('SegmentTimeline');
|
||||||
const segments = [];
|
const segments: DashSegment[] = [];
|
||||||
|
|
||||||
if (segmentTimeline) {
|
if (segmentTimeline) {
|
||||||
const sElements = segmentTimeline.querySelectorAll('S');
|
const sElements = segmentTimeline.querySelectorAll('S');
|
||||||
|
|
||||||
let currentTime = 0;
|
let currentTime = 0;
|
||||||
let currentNumber = startNumber;
|
let currentNumber = startNumber;
|
||||||
|
|
||||||
sElements.forEach((s) => {
|
sElements.forEach((s) => {
|
||||||
// t is optional, defaults to previous end
|
|
||||||
const tAttr = s.getAttribute('t');
|
const tAttr = s.getAttribute('t');
|
||||||
if (tAttr) currentTime = parseInt(tAttr, 10);
|
if (tAttr) currentTime = parseInt(tAttr, 10);
|
||||||
|
|
||||||
const d = parseInt(s.getAttribute('d'), 10);
|
const d = parseInt(s.getAttribute('d') || '0', 10);
|
||||||
const r = parseInt(s.getAttribute('r') || '0', 10);
|
const r = parseInt(s.getAttribute('r') || '0', 10);
|
||||||
|
|
||||||
// Initial segment
|
|
||||||
segments.push({ number: currentNumber, time: currentTime });
|
segments.push({ number: currentNumber, time: currentTime });
|
||||||
|
|
||||||
currentTime += d;
|
currentTime += d;
|
||||||
currentNumber++;
|
currentNumber++;
|
||||||
|
|
||||||
// Repeats
|
|
||||||
// r is the number of REPEATS (so total occurrences = 1 + r)
|
|
||||||
// If r is negative, it refers to open-ended? (Usually not in static manifests)
|
|
||||||
for (let i = 0; i < r; i++) {
|
for (let i = 0; i < r; i++) {
|
||||||
segments.push({ number: currentNumber, time: currentTime });
|
segments.push({ number: currentNumber, time: currentTime });
|
||||||
currentTime += d;
|
currentTime += d;
|
||||||
|
|
@ -163,43 +196,40 @@ export class DashDownloader {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
generateSegmentUrls(manifest) {
|
generateSegmentUrls(manifest: DashManifest): string[] {
|
||||||
const { baseUrl, initialization, media, segments, repId } = manifest;
|
const { baseUrl, initialization, media, segments, repId } = manifest;
|
||||||
const urls = [];
|
|
||||||
|
|
||||||
// Helper to resolve template strings
|
const urls: string[] = [];
|
||||||
const resolveTemplate = (template, number, time) => {
|
|
||||||
|
const resolveTemplate = (template: string, number: number, time: number): string => {
|
||||||
return template
|
return template
|
||||||
.replace(/\$RepresentationID\$/g, repId)
|
.replace(/\$RepresentationID\$/g, repId ?? '')
|
||||||
.replace(/\$Number(?:%0([0-9]+)d)?\$/g, (match, width) => {
|
.replace(/\$Number(?:%0([0-9]+)d)?\$/g, (_, width) => {
|
||||||
if (width) {
|
if (width) {
|
||||||
return number.toString().padStart(parseInt(width), '0');
|
return number.toString().padStart(parseInt(width), '0');
|
||||||
}
|
}
|
||||||
return number;
|
return number.toString();
|
||||||
})
|
})
|
||||||
.replace(/\$Time(?:%0([0-9]+)d)?\$/g, (match, width) => {
|
.replace(/\$Time(?:%0([0-9]+)d)?\$/g, (_, width) => {
|
||||||
if (width) {
|
if (width) {
|
||||||
return time.toString().padStart(parseInt(width), '0');
|
return time.toString().padStart(parseInt(width), '0');
|
||||||
}
|
}
|
||||||
return time;
|
return time.toString();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to join paths handling slashes
|
const joinPath = (base: string, part: string): string => {
|
||||||
const joinPath = (base, part) => {
|
|
||||||
if (!base) return part;
|
if (!base) return part;
|
||||||
if (part.startsWith('http')) return part; // Absolute path
|
if (part.startsWith('http')) return part;
|
||||||
return base.endsWith('/') ? base + part : base + '/' + part;
|
return base.endsWith('/') ? base + part : base + '/' + part;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Initialization Segment
|
|
||||||
if (initialization) {
|
if (initialization) {
|
||||||
const initPath = resolveTemplate(initialization, 0, 0); // Init often doesn't use Number/Time but just in case
|
const initPath = resolveTemplate(initialization, 0, 0);
|
||||||
urls.push(joinPath(baseUrl, initPath));
|
urls.push(joinPath(baseUrl, initPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Media Segments
|
if (media && segments.length > 0) {
|
||||||
if (segments && segments.length > 0) {
|
|
||||||
segments.forEach((seg) => {
|
segments.forEach((seg) => {
|
||||||
const path = resolveTemplate(media, seg.number, seg.time);
|
const path = resolveTemplate(media, seg.number, seg.time);
|
||||||
urls.push(joinPath(baseUrl, path));
|
urls.push(joinPath(baseUrl, path));
|
||||||
303
js/downloads.js
303
js/downloads.js
|
|
@ -11,17 +11,20 @@ import {
|
||||||
getExtensionFromBlob,
|
getExtensionFromBlob,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
getTrackDiscNumber,
|
getTrackDiscNumber,
|
||||||
getFullArtistString,
|
|
||||||
getMimeType,
|
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
|
import { AbortError } from './errorTypes.ts';
|
||||||
import { lyricsSettings, bulkDownloadSettings, playlistSettings } from './storage.js';
|
import { lyricsSettings, bulkDownloadSettings, playlistSettings } from './storage.js';
|
||||||
import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
|
|
||||||
import { DashDownloader } from './dash-downloader.js';
|
|
||||||
import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js';
|
import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js';
|
||||||
import { loadFfmpeg } from './ffmpeg.js';
|
import { triggerDownload } from './download-utils.ts';
|
||||||
import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts';
|
import {
|
||||||
import { isCustomFormat } from './ffmpegFormats.ts';
|
ZipStreamWriter,
|
||||||
import { ZipStreamWriter, ZipBlobWriter, ZipNeutralinoWriter, FolderPickerWriter } from './bulk-download-writer.ts';
|
ZipBlobWriter,
|
||||||
|
ZipNeutralinoWriter,
|
||||||
|
FolderPickerWriter,
|
||||||
|
SequentialFileWriter,
|
||||||
|
} from './bulk-download-writer.ts';
|
||||||
|
import { FfmpegProgress } from './ffmpeg.types.js';
|
||||||
|
import { DownloadProgress, ProgressMessage, SegmentedDownloadProgress } from './progressEvents.js';
|
||||||
|
|
||||||
const downloadTasks = new Map();
|
const downloadTasks = new Map();
|
||||||
const bulkDownloadTasks = new Map();
|
const bulkDownloadTasks = new Map();
|
||||||
|
|
@ -209,7 +212,7 @@ export function updateDownloadProgress(trackId, progress) {
|
||||||
const progressFill = taskEl.querySelector('.download-progress-fill');
|
const progressFill = taskEl.querySelector('.download-progress-fill');
|
||||||
const statusEl = taskEl.querySelector('.download-status');
|
const statusEl = taskEl.querySelector('.download-status');
|
||||||
|
|
||||||
if (progress.stage === 'downloading') {
|
if (progress instanceof DownloadProgress && progress.receivedBytes && progress.totalBytes) {
|
||||||
const percent = progress.totalBytes ? Math.round((progress.receivedBytes / progress.totalBytes) * 100) : 0;
|
const percent = progress.totalBytes ? Math.round((progress.receivedBytes / progress.totalBytes) * 100) : 0;
|
||||||
|
|
||||||
progressFill.style.width = `${percent}%`;
|
progressFill.style.width = `${percent}%`;
|
||||||
|
|
@ -219,15 +222,25 @@ export function updateDownloadProgress(trackId, progress) {
|
||||||
const totalMB = progress.totalBytes ? (progress.totalBytes / (1024 * 1024)).toFixed(1) : '?';
|
const totalMB = progress.totalBytes ? (progress.totalBytes / (1024 * 1024)).toFixed(1) : '?';
|
||||||
|
|
||||||
statusEl.textContent = `Downloading: ${receivedMB}MB / ${totalMB}MB (${percent}%)`;
|
statusEl.textContent = `Downloading: ${receivedMB}MB / ${totalMB}MB (${percent}%)`;
|
||||||
} else if (progress.stage === 'encoding') {
|
} else if (progress instanceof SegmentedDownloadProgress && progress.currentSegment && progress.totalSegments) {
|
||||||
|
const percent = progress.totalBytes ? Math.round((progress.currentSegment / progress.totalSegments) * 100) : 0;
|
||||||
|
|
||||||
|
progressFill.style.width = `${percent}%`;
|
||||||
|
progressFill.style.background = 'var(--highlight)';
|
||||||
|
|
||||||
|
const receivedMB = (progress.receivedBytes / (1024 * 1024)).toFixed(1);
|
||||||
|
const totalMB = progress.totalBytes ? (progress.totalBytes / (1024 * 1024)).toFixed(1) : '?';
|
||||||
|
|
||||||
|
statusEl.textContent = `Downloading: ${receivedMB}MB / ${totalMB}MB (${percent}%)`;
|
||||||
|
} else if (progress instanceof FfmpegProgress && progress.stage == 'encoding') {
|
||||||
const percent = progress.progress ? Math.round(progress.progress) : 0;
|
const percent = progress.progress ? Math.round(progress.progress) : 0;
|
||||||
progressFill.style.width = `${percent}%`;
|
progressFill.style.width = `${percent}%`;
|
||||||
progressFill.style.background = '#3b82f6'; // Blue for encoding
|
progressFill.style.background = '#3b82f6'; // Blue for encoding
|
||||||
statusEl.textContent = `Converting: ${percent}%`;
|
statusEl.textContent = `Converting: ${percent}%`;
|
||||||
} else if (progress.stage === 'finalizing' || progress.stage === 'processing') {
|
} else if (progress instanceof ProgressMessage || progress.message) {
|
||||||
progressFill.style.width = '100%';
|
progressFill.style.width = '100%';
|
||||||
progressFill.style.background = '#3b82f6';
|
progressFill.style.background = '#3b82f6';
|
||||||
statusEl.textContent = progress.message || 'Processing...';
|
statusEl.textContent = progress.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -296,210 +309,22 @@ function removeBulkDownloadTask(notifEl) {
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadTrackBlob(
|
async function downloadTrackBlob(track, quality, api, signal = null, onProgress = null) {
|
||||||
|
const blob = await api.downloadTrack(track.id, quality, undefined, {
|
||||||
track,
|
track,
|
||||||
quality,
|
signal,
|
||||||
api,
|
onProgress,
|
||||||
lyricsManager = null,
|
triggerDownload: false,
|
||||||
signal = null,
|
calculateDashBytes: false,
|
||||||
onProgress = null,
|
|
||||||
coverBlob = null
|
|
||||||
) {
|
|
||||||
// Load ffmpeg in the background.
|
|
||||||
loadFfmpeg().catch(console.error);
|
|
||||||
|
|
||||||
const prefetchPromises = prefetchMetadataObjects(track, api, coverBlob);
|
|
||||||
|
|
||||||
let enrichedTrack = {
|
|
||||||
...track,
|
|
||||||
artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Custom FFMPEG formats are not native TIDAL qualities; download LOSSLESS and transcode
|
|
||||||
const downloadQuality = isCustomFormat(quality) ? 'LOSSLESS' : quality;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fullTrack = await api.getTrackMetadata(track.id);
|
|
||||||
if (fullTrack) {
|
|
||||||
enrichedTrack = {
|
|
||||||
...fullTrack,
|
|
||||||
...enrichedTrack,
|
|
||||||
artist: enrichedTrack.artist || fullTrack.artist,
|
|
||||||
album: {
|
|
||||||
...(fullTrack.album || {}),
|
|
||||||
...(enrichedTrack.album || {}),
|
|
||||||
},
|
|
||||||
// Preserve explicit disc fields from either source
|
|
||||||
discNumber: enrichedTrack.discNumber ?? fullTrack.discNumber,
|
|
||||||
volumeNumber: enrichedTrack.volumeNumber ?? fullTrack.volumeNumber,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Non-fatal: continue with best 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 lookup = await api.getTrack(track.id, downloadQuality);
|
|
||||||
let streamUrl;
|
|
||||||
|
|
||||||
if (lookup.originalTrackUrl) {
|
|
||||||
streamUrl = lookup.originalTrackUrl;
|
|
||||||
} else {
|
|
||||||
streamUrl = api.extractStreamUrlFromManifest(lookup.info.manifest);
|
|
||||||
if (!streamUrl) {
|
|
||||||
throw new Error('Could not resolve stream URL');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lookup.info) {
|
|
||||||
enrichedTrack.replayGain = {
|
|
||||||
trackReplayGain: lookup.info.trackReplayGain,
|
|
||||||
trackPeakAmplitude: lookup.info.trackPeakAmplitude,
|
|
||||||
albumReplayGain: lookup.info.albumReplayGain,
|
|
||||||
albumPeakAmplitude: lookup.info.albumPeakAmplitude,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle DASH streams (blob URLs)
|
|
||||||
let blob;
|
|
||||||
if (streamUrl.startsWith('blob:')) {
|
|
||||||
try {
|
|
||||||
const downloader = new DashDownloader();
|
|
||||||
blob = await downloader.downloadDashStream(streamUrl, { signal });
|
|
||||||
} catch (dashError) {
|
|
||||||
console.error('DASH download failed:', dashError);
|
|
||||||
// Fallback
|
|
||||||
if (downloadQuality !== 'LOSSLESS') {
|
|
||||||
console.warn('Falling back to LOSSLESS (16-bit) download.');
|
|
||||||
return downloadTrackBlob(track, 'LOSSLESS', api, lyricsManager, signal, onProgress, coverBlob);
|
|
||||||
}
|
|
||||||
throw dashError;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const response = await fetch(streamUrl, { signal });
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch track: ${response.status}`);
|
|
||||||
}
|
|
||||||
blob = await response.blob();
|
|
||||||
}
|
|
||||||
|
|
||||||
const coverBlobToEmbed = await prefetchPromises.coverFetch;
|
|
||||||
const extraFiles = [];
|
|
||||||
const ffmpegMetadataArgs = [];
|
|
||||||
|
|
||||||
if (coverBlobToEmbed) {
|
|
||||||
const coverBuffer = await coverBlobToEmbed.arrayBuffer();
|
|
||||||
const coverExt = getMimeType(new Uint8Array(coverBuffer)) === 'image/png' ? 'png' : 'jpg';
|
|
||||||
const coverName = `cover.${coverExt}`;
|
|
||||||
extraFiles.push({
|
|
||||||
name: coverName,
|
|
||||||
data: coverBuffer,
|
|
||||||
});
|
});
|
||||||
ffmpegMetadataArgs.push('-i', coverName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enrichedTrack) {
|
|
||||||
ffmpegMetadataArgs.push(
|
|
||||||
'-metadata',
|
|
||||||
`title=${getTrackTitle(enrichedTrack)}`,
|
|
||||||
'-metadata',
|
|
||||||
`artist=${getFullArtistString(enrichedTrack)}`,
|
|
||||||
'-metadata',
|
|
||||||
`album=${enrichedTrack.album?.title || ''}`,
|
|
||||||
'-metadata',
|
|
||||||
`album_artist=${enrichedTrack.album?.artist?.name || enrichedTrack.artist?.name || ''}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const trackNum = enrichedTrack.trackNumber;
|
|
||||||
if (trackNum) {
|
|
||||||
const totalTracks = enrichedTrack.album?.numberOfTracks;
|
|
||||||
ffmpegMetadataArgs.push('-metadata', `track=${trackNum}${totalTracks ? `/${totalTracks}` : ''}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const discNum = enrichedTrack.volumeNumber || enrichedTrack.discNumber;
|
|
||||||
if (discNum) {
|
|
||||||
ffmpegMetadataArgs.push('-metadata', `disc=${discNum}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const releaseDate = enrichedTrack.album?.releaseDate || enrichedTrack?.streamStartDate;
|
|
||||||
if (releaseDate) {
|
|
||||||
ffmpegMetadataArgs.push('-metadata', `date=${releaseDate.split('-')[0]}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply audio post-processing (custom format transcoding + lossless container conversion)
|
|
||||||
blob = await applyAudioPostProcessing(blob, quality, onProgress, signal);
|
|
||||||
|
|
||||||
// Detect actual format from blob signature BEFORE adding metadata
|
// Detect actual format from blob signature BEFORE adding metadata
|
||||||
const extension = await getExtensionFromBlob(blob);
|
const extension = await getExtensionFromBlob(blob);
|
||||||
|
|
||||||
// Add metadata to the blob
|
|
||||||
blob = await addMetadataToAudio(blob, enrichedTrack, api, quality, prefetchPromises);
|
|
||||||
|
|
||||||
return { blob, extension };
|
return { blob, extension };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification, coverBlob = null) {
|
async function bulkDownload(
|
||||||
const { abortController } = bulkDownloadTasks.get(notification);
|
|
||||||
const signal = abortController.signal;
|
|
||||||
|
|
||||||
for (let i = 0; i < tracks.length; i++) {
|
|
||||||
if (signal.aborted) break;
|
|
||||||
const track = tracks[i];
|
|
||||||
const trackTitle = getTrackTitle(track);
|
|
||||||
|
|
||||||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, null, coverBlob);
|
|
||||||
const filename = buildTrackFilename(track, quality, extension);
|
|
||||||
triggerDownload(blob, filename);
|
|
||||||
|
|
||||||
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
|
||||||
try {
|
|
||||||
const lyricsData = await lyricsManager.fetchLyrics(track.id, track);
|
|
||||||
if (lyricsData) {
|
|
||||||
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
|
|
||||||
if (lrcContent) {
|
|
||||||
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
|
||||||
const lrcBlob = new Blob([lrcContent], { type: 'text/plain' });
|
|
||||||
triggerDownload(lrcBlob, lrcFilename);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silent fail for lyrics
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (err.name === 'AbortError') throw err;
|
|
||||||
console.error(`Failed to download track ${trackTitle}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bulkDownloadToZip(
|
|
||||||
tracks,
|
tracks,
|
||||||
folderName,
|
folderName,
|
||||||
api,
|
api,
|
||||||
|
|
@ -530,21 +355,21 @@ async function bulkDownloadToZip(
|
||||||
if (signal.aborted) break;
|
if (signal.aborted) break;
|
||||||
const track = tracks[i];
|
const track = tracks[i];
|
||||||
const trackTitle = getTrackTitle(track);
|
const trackTitle = getTrackTitle(track);
|
||||||
|
let fileFraction = 0;
|
||||||
|
|
||||||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
|
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { blob, extension } = await downloadTrackBlob(
|
const { blob, extension } = await downloadTrackBlob(track, quality, api, signal, (p) => {
|
||||||
track,
|
if (p instanceof DownloadProgress && p.totalBytes && p.receivedBytes) {
|
||||||
quality,
|
fileFraction = p.receivedBytes / p.totalBytes;
|
||||||
api,
|
} else if (p instanceof SegmentedDownloadProgress && p.currentSegment && p.totalSegments) {
|
||||||
null,
|
fileFraction = p.currentSegment / p.totalSegments;
|
||||||
signal,
|
}
|
||||||
(p) => {
|
|
||||||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
|
fileFraction = Math.min(fileFraction, 0.99); // Cap at 99% to avoid showing 100% before finalization
|
||||||
},
|
updateBulkDownloadProgress(notification, i + fileFraction, tracks.length, trackTitle, p);
|
||||||
coverBlob
|
});
|
||||||
);
|
|
||||||
const filename = buildTrackFilename(track, quality, extension);
|
const filename = buildTrackFilename(track, quality, extension);
|
||||||
const discNumber = discLayout.resolveDiscNumber(i);
|
const discNumber = discLayout.resolveDiscNumber(i);
|
||||||
const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename;
|
const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename;
|
||||||
|
|
@ -691,7 +516,7 @@ async function createBulkWriter(folderName) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (method === 'individual') {
|
if (method === 'individual') {
|
||||||
return null;
|
return new SequentialFileWriter();
|
||||||
}
|
}
|
||||||
// method === 'zip' (or folder picker unavailable as fallback)
|
// method === 'zip' (or folder picker unavailable as fallback)
|
||||||
if (!forceZipBlob && hasFileSystemAccess) {
|
if (!forceZipBlob && hasFileSystemAccess) {
|
||||||
|
|
@ -717,7 +542,7 @@ async function startBulkDownload(
|
||||||
const writer = await createBulkWriter(defaultName);
|
const writer = await createBulkWriter(defaultName);
|
||||||
|
|
||||||
if (writer) {
|
if (writer) {
|
||||||
await bulkDownloadToZip(
|
await bulkDownload(
|
||||||
tracks,
|
tracks,
|
||||||
defaultName,
|
defaultName,
|
||||||
api,
|
api,
|
||||||
|
|
@ -729,9 +554,6 @@ async function startBulkDownload(
|
||||||
type,
|
type,
|
||||||
metadata
|
metadata
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
// Individual sequential downloads
|
|
||||||
await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
completeBulkDownload(notification, true);
|
completeBulkDownload(notification, true);
|
||||||
|
|
@ -846,15 +668,7 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
||||||
const track = tracks[i];
|
const track = tracks[i];
|
||||||
if (signal.aborted) break;
|
if (signal.aborted) break;
|
||||||
try {
|
try {
|
||||||
const { blob, extension } = await downloadTrackBlob(
|
const { blob, extension } = await downloadTrackBlob(track, quality, api, signal, null);
|
||||||
track,
|
|
||||||
quality,
|
|
||||||
api,
|
|
||||||
null,
|
|
||||||
signal,
|
|
||||||
null,
|
|
||||||
coverBlob
|
|
||||||
);
|
|
||||||
const filename = buildTrackFilename(track, quality, extension);
|
const filename = buildTrackFilename(track, quality, extension);
|
||||||
const discNumber = discLayout.resolveDiscNumber(i);
|
const discNumber = discLayout.resolveDiscNumber(i);
|
||||||
const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename;
|
const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename;
|
||||||
|
|
@ -954,16 +768,6 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
||||||
|
|
||||||
if (writer) {
|
if (writer) {
|
||||||
await writer.write(yieldDiscography());
|
await writer.write(yieldDiscography());
|
||||||
} else {
|
|
||||||
// Individual sequential downloads for discography
|
|
||||||
for (let albumIndex = 0; albumIndex < selectedReleases.length; albumIndex++) {
|
|
||||||
if (signal.aborted) break;
|
|
||||||
const album = selectedReleases[albumIndex];
|
|
||||||
updateBulkDownloadProgress(notification, albumIndex, selectedReleases.length, album.title);
|
|
||||||
const { tracks: rawTracks } = await api.getAlbum(album.id);
|
|
||||||
const tracks = await annotateTracksWithDiscInfo(rawTracks, api);
|
|
||||||
await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
completeBulkDownload(notification, true);
|
completeBulkDownload(notification, true);
|
||||||
|
|
@ -1026,22 +830,26 @@ function createBulkDownloadNotification(type, name, _totalItems) {
|
||||||
return notifEl;
|
return notifEl;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBulkDownloadProgress(notifEl, current, total, currentItem, ffmpegProgress = null) {
|
function updateBulkDownloadProgress(notifEl, current, total, currentItem, progress = null) {
|
||||||
const progressFill = notifEl.querySelector('.download-progress-fill');
|
const progressFill = notifEl.querySelector('.download-progress-fill');
|
||||||
const statusEl = notifEl.querySelector('.download-status');
|
const statusEl = notifEl.querySelector('.download-status');
|
||||||
|
|
||||||
if (ffmpegProgress && (ffmpegProgress.stage === 'encoding' || ffmpegProgress.stage === 'finalizing')) {
|
if (progress instanceof FfmpegProgress) {
|
||||||
const percent = ffmpegProgress.progress ? Math.round(ffmpegProgress.progress) : 100;
|
const percent = progress.progress || 0;
|
||||||
progressFill.style.width = `${percent}%`;
|
progressFill.style.width = `${percent}%`;
|
||||||
progressFill.style.background = '#3b82f6'; // Blue for encoding
|
progressFill.style.background = '#3b82f6'; // Blue for encoding
|
||||||
statusEl.textContent = `Converting ${current}/${total}: ${percent}%`;
|
statusEl.textContent = `Converting ${Math.ceil(current)}/${total}: ${Math.round(percent)}%`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (progress instanceof ProgressMessage) {
|
||||||
|
statusEl.textContent = progress.message;
|
||||||
|
}
|
||||||
|
|
||||||
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
|
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||||
progressFill.style.width = `${percent}%`;
|
progressFill.style.width = `${percent}%`;
|
||||||
progressFill.style.background = 'var(--highlight)';
|
progressFill.style.background = 'var(--highlight)';
|
||||||
statusEl.textContent = `${current}/${total} - ${currentItem}`;
|
statusEl.textContent = `${Math.floor(current)}/${total} - ${currentItem}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeBulkDownload(notifEl, success = true, message = null) {
|
function completeBulkDownload(notifEl, success = true, message = null) {
|
||||||
|
|
@ -1143,6 +951,7 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
updateDownloadProgress(track.id, progress);
|
updateDownloadProgress(track.id, progress);
|
||||||
},
|
},
|
||||||
|
calculateDashBytes: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
completeDownloadTask(track.id, true);
|
completeDownloadTask(track.id, true);
|
||||||
|
|
|
||||||
6
js/errorTypes.ts
Normal file
6
js/errorTypes.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export class AbortError extends Error {
|
||||||
|
constructor(cause: string = 'The task was aborted.') {
|
||||||
|
super(cause);
|
||||||
|
this.name = 'AbortError';
|
||||||
|
}
|
||||||
|
}
|
||||||
38
js/ffmpeg.js
38
js/ffmpeg.js
|
|
@ -1,7 +1,10 @@
|
||||||
import { fetchBlobURL } from './utils';
|
import { fetchBlobURL } from './utils';
|
||||||
import FfmpegWorker from './ffmpeg.worker.js?worker';
|
import FfmpegWorker from './ffmpeg.worker.js?worker';
|
||||||
import coreJs from '!/@ffmpeg/core/dist/esm/ffmpeg-core.js?url';
|
import { FfmpegProgress } from './ffmpeg.types';
|
||||||
import coreWasm from '!/@ffmpeg/core/dist/esm/ffmpeg-core.wasm?url';
|
import { ProgressMessage } from './progressEvents';
|
||||||
|
const ffmpegBase = 'https://unpkg.com/@ffmpeg/core/dist/esm';
|
||||||
|
const coreJs = `${ffmpegBase}/ffmpeg-core.js`;
|
||||||
|
const coreWasm = `${ffmpegBase}/ffmpeg-core.wasm`;
|
||||||
|
|
||||||
class FfmpegError extends Error {
|
class FfmpegError extends Error {
|
||||||
constructor(message) {
|
constructor(message) {
|
||||||
|
|
@ -25,6 +28,17 @@ export function loadFfmpeg() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {Blob} audioBlob
|
||||||
|
* @param {string[]} args
|
||||||
|
* @param {string} outputName
|
||||||
|
* @param {string} outputMime
|
||||||
|
* @param {(progress: import('./ffmpeg.types.ts').FfmpegProgress) => void} onProgress
|
||||||
|
* @param {AbortSignal|null} signal
|
||||||
|
* @param {Array<{name: string, data: ArrayBuffer | Uint8Array}>} extraFiles
|
||||||
|
* @returns {Promise<Blob>} Encoded audio blob
|
||||||
|
*/
|
||||||
async function ffmpegWorker(
|
async function ffmpegWorker(
|
||||||
audioBlob,
|
audioBlob,
|
||||||
args = [],
|
args = [],
|
||||||
|
|
@ -65,8 +79,10 @@ async function ffmpegWorker(
|
||||||
if (signal) signal.removeEventListener('abort', abortHandler);
|
if (signal) signal.removeEventListener('abort', abortHandler);
|
||||||
worker.terminate();
|
worker.terminate();
|
||||||
reject(new FfmpegError(message));
|
reject(new FfmpegError(message));
|
||||||
} else if (type === 'progress' && onProgress) {
|
} else if (type === 'progress' && message) {
|
||||||
onProgress({ stage, message, progress });
|
onProgress?.(new FfmpegProgress(stage, progress || 0, message));
|
||||||
|
} else if (type === 'progress' && stage != 'loading' && progress !== null) {
|
||||||
|
onProgress?.(new FfmpegProgress(stage, progress || 0, message));
|
||||||
} else if (type === 'log') {
|
} else if (type === 'log') {
|
||||||
console.log('[FFmpeg]', message);
|
console.log('[FFmpeg]', message);
|
||||||
}
|
}
|
||||||
|
|
@ -106,6 +122,20 @@ async function ffmpegWorker(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes audio using FFmpeg via Web Worker
|
||||||
|
* @async
|
||||||
|
* @param {Blob} audioBlob - The audio blob to encode
|
||||||
|
* @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 {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
|
||||||
|
* @throws {FfmpegError} If Web Workers are not available
|
||||||
|
* @throws {Error} If FFmpeg encoding fails
|
||||||
|
*/
|
||||||
export async function ffmpeg(
|
export async function ffmpeg(
|
||||||
audioBlob,
|
audioBlob,
|
||||||
args = [],
|
args = [],
|
||||||
|
|
|
||||||
7
js/ffmpeg.types.ts
Normal file
7
js/ffmpeg.types.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export class FfmpegProgress implements MonochromeProgress {
|
||||||
|
constructor(
|
||||||
|
public readonly stage: 'loading' | 'encoding' | 'finalizing',
|
||||||
|
public readonly progress: number,
|
||||||
|
public readonly message?: string
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { SegmentedDownloadProgress } from './progressEvents';
|
||||||
|
|
||||||
export class HlsDownloader {
|
export class HlsDownloader {
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
|
|
@ -24,6 +26,8 @@ export class HlsDownloader {
|
||||||
for (let i = 0; i < totalSegments; i++) {
|
for (let i = 0; i < totalSegments; i++) {
|
||||||
if (signal?.aborted) throw new Error('AbortError');
|
if (signal?.aborted) throw new Error('AbortError');
|
||||||
|
|
||||||
|
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, undefined, i, totalSegments));
|
||||||
|
|
||||||
const segmentUrl = segments[i];
|
const segmentUrl = segments[i];
|
||||||
const segmentResponse = await fetch(segmentUrl, { signal });
|
const segmentResponse = await fetch(segmentUrl, { signal });
|
||||||
|
|
||||||
|
|
@ -35,15 +39,7 @@ export class HlsDownloader {
|
||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
downloadedBytes += chunk.byteLength;
|
downloadedBytes += chunk.byteLength;
|
||||||
|
|
||||||
if (onProgress) {
|
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, undefined, i + 1, totalSegments));
|
||||||
onProgress({
|
|
||||||
stage: 'downloading',
|
|
||||||
receivedBytes: downloadedBytes,
|
|
||||||
totalBytes: undefined,
|
|
||||||
currentSegment: i + 1,
|
|
||||||
totalSegments: totalSegments,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mimeType = segments[0].endsWith('.m4s') || segments[0].includes('mp4') ? 'video/mp4' : 'video/mp2t';
|
const mimeType = segments[0].endsWith('.m4s') || segments[0].includes('mp4') ? 'video/mp4' : 'video/mp2t';
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,13 @@ class MP3EncodingError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {Blob} audioBlob
|
||||||
|
* @param {(progress: import('./ffmpeg.types.ts').FfmpegProgress) => void} [onProgress=null]
|
||||||
|
* @param {AbortSignal|null} [signal=null]
|
||||||
|
* @returns {Promise<Blob>} Encoded MP3 audio blob
|
||||||
|
*/
|
||||||
export async function encodeToMp3(audioBlob, onProgress = null, signal = null) {
|
export async function encodeToMp3(audioBlob, onProgress = null, signal = null) {
|
||||||
try {
|
try {
|
||||||
// Use Web Worker for non-blocking FFmpeg encoding
|
// Use Web Worker for non-blocking FFmpeg encoding
|
||||||
|
|
|
||||||
43
js/progressEvents.ts
Normal file
43
js/progressEvents.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
declare global {
|
||||||
|
type MonochromeProgress<T = {}> = {
|
||||||
|
stage: string;
|
||||||
|
} & T;
|
||||||
|
|
||||||
|
type MonochromeProgressMessage<T = MonochromeProgress> = {
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MonochromeProgressListener<T = MonochromeProgress> = (progress: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DownloadProgress implements MonochromeProgress {
|
||||||
|
public readonly stage = 'downloading';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly receivedBytes: number,
|
||||||
|
public readonly totalBytes: number | undefined
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SegmentedDownloadProgress extends DownloadProgress {
|
||||||
|
public readonly stage = 'downloading';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly receivedBytes: number,
|
||||||
|
public readonly totalBytes: number | undefined,
|
||||||
|
public readonly currentSegment: number,
|
||||||
|
public readonly totalSegments: number
|
||||||
|
) {
|
||||||
|
super(receivedBytes, totalBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProgressMessage implements MonochromeProgressMessage {
|
||||||
|
constructor(public readonly message: string) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DownloadProgressMessage extends ProgressMessage {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,12 +2,9 @@ import { TagLib } from 'taglib-wasm';
|
||||||
import { fetchBlobURL } from './utils';
|
import { fetchBlobURL } from './utils';
|
||||||
import _TagLibWasm from '!/taglib-wasm/dist/taglib-web.wasm?url';
|
import _TagLibWasm from '!/taglib-wasm/dist/taglib-web.wasm?url';
|
||||||
import type {
|
import type {
|
||||||
TagLibWorkerMessageType,
|
|
||||||
AddMetadataMessage,
|
AddMetadataMessage,
|
||||||
GetMetadataMessage,
|
|
||||||
TagLibFileResponse,
|
TagLibFileResponse,
|
||||||
TagLibMetadataResponse,
|
TagLibMetadataResponse,
|
||||||
TagLibMetadata,
|
|
||||||
TagLibReadMetadata,
|
TagLibReadMetadata,
|
||||||
} from './taglib.types';
|
} from './taglib.types';
|
||||||
import TagLibWorker from './taglib.worker?worker';
|
import TagLibWorker from './taglib.worker?worker';
|
||||||
|
|
@ -62,7 +59,7 @@ export async function getMetadataWithTagLib(audioData: Uint8Array) {
|
||||||
audioData = new Uint8Array(audioData);
|
audioData = new Uint8Array(audioData);
|
||||||
}
|
}
|
||||||
|
|
||||||
const worker = new Worker(new URL(TagLibWorker, import.meta.url), { type: 'module' });
|
const worker = new TagLibWorker();
|
||||||
const wasmUrl = await fetchTagLib();
|
const wasmUrl = await fetchTagLib();
|
||||||
|
|
||||||
return new Promise<TagLibReadMetadata>((resolve, reject) => {
|
return new Promise<TagLibReadMetadata>((resolve, reject) => {
|
||||||
|
|
|
||||||
|
|
@ -261,9 +261,14 @@ self.onmessage = async (event: MessageEvent<TagLibWorkerMessage>) => {
|
||||||
|
|
||||||
switch (event.data.type) {
|
switch (event.data.type) {
|
||||||
case 'Add':
|
case 'Add':
|
||||||
|
if ((event.data as AddMetadataMessage).cover?.data?.buffer instanceof ArrayBuffer) {
|
||||||
|
transfer.push((event.data as AddMetadataMessage).cover.data.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await addMetadataToAudio(event.data as AddMetadataMessage);
|
const result = await addMetadataToAudio(event.data as AddMetadataMessage);
|
||||||
transfer.push(result.buffer);
|
transfer.push(result.buffer);
|
||||||
|
|
||||||
self.postMessage(
|
self.postMessage(
|
||||||
{
|
{
|
||||||
type: event.data.type,
|
type: event.data.type,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue