Merge pull request #417 from DanTheMan827/download-fixes
Download fixes
This commit is contained in:
commit
022c27056b
15 changed files with 379 additions and 169 deletions
77
index.html
77
index.html
|
|
@ -4075,6 +4075,49 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-tab-content" id="settings-tab-downloads">
|
<div class="settings-tab-content" id="settings-tab-downloads">
|
||||||
<div class="settings-list">
|
<div class="settings-list">
|
||||||
|
<div class="settings-group">
|
||||||
|
<div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Download Quality</span>
|
||||||
|
<span class="description">Quality for track downloads</span>
|
||||||
|
</div>
|
||||||
|
<select id="download-quality-setting">
|
||||||
|
<option value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit)</option>
|
||||||
|
<option value="LOSSLESS">Lossless (16-bit)</option>
|
||||||
|
<option value="HIGH">AAC 320kbps</option>
|
||||||
|
<option value="LOW">AAC 96kbps</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item" id="hi-res-download-warning" style="display: none">
|
||||||
|
<div class="info setting-details">
|
||||||
|
<span class="description">
|
||||||
|
24-bit downloads may crash the browser on some devices, or be missing
|
||||||
|
metadata.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Dolby Atmos</span>
|
||||||
|
<span class="description">Prefer Dolby Atmos tracks when available</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="dolby-atmos-toggle" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Lossless Container</span>
|
||||||
|
<span class="description">Container format for lossless downloads</span>
|
||||||
|
</div>
|
||||||
|
<select id="lossless-container-setting">
|
||||||
|
<option value="nochange">Don't change</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
|
|
@ -4135,6 +4178,8 @@
|
||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-group">
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="label">Download Lyrics</span>
|
<span class="label">Download Lyrics</span>
|
||||||
|
|
@ -4159,38 +4204,6 @@
|
||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="info">
|
|
||||||
<span class="label">Download Quality</span>
|
|
||||||
<span class="description">Quality for track downloads</span>
|
|
||||||
</div>
|
|
||||||
<select id="download-quality-setting">
|
|
||||||
<option value="DOLBY_ATMOS">Dolby Atmos (MP4)</option>
|
|
||||||
<option value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit)</option>
|
|
||||||
<option value="LOSSLESS">Lossless (16-bit)</option>
|
|
||||||
<option value="HIGH">AAC 320kbps</option>
|
|
||||||
<option value="LOW">AAC 96kbps</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="setting-item" id="hi-res-download-warning" style="display: none">
|
|
||||||
<div class="info setting-details">
|
|
||||||
<span class="description">
|
|
||||||
24-bit downloads may crash the browser on some devices, or be missing
|
|
||||||
metadata.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="info">
|
|
||||||
<span class="label">Lossless Container</span>
|
|
||||||
<span class="description">Container format for lossless downloads</span>
|
|
||||||
</div>
|
|
||||||
<select id="lossless-container-setting">
|
|
||||||
<option value="nochange">Don't change</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="label">Cover Art Size</span>
|
<span class="label">Cover Art Size</span>
|
||||||
|
|
|
||||||
85
js/api.js
85
js/api.js
|
|
@ -10,11 +10,10 @@ import {
|
||||||
getTrackDiscNumber,
|
getTrackDiscNumber,
|
||||||
getMimeType,
|
getMimeType,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
import { trackDateSettings } from './storage.js';
|
import { preferDolbyAtmosSettings, trackDateSettings } from './storage.js';
|
||||||
import { APICache } from './cache.js';
|
import { APICache } from './cache.js';
|
||||||
import { DashDownloader } from './dash-downloader.ts';
|
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 { loadFfmpeg, FfmpegError, ffmpeg } from './ffmpeg.js';
|
import { loadFfmpeg, FfmpegError, ffmpeg } 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';
|
||||||
|
|
@ -31,6 +30,8 @@ import {
|
||||||
PlaybackInfo,
|
PlaybackInfo,
|
||||||
Track,
|
Track,
|
||||||
Album,
|
Album,
|
||||||
|
PreparedVideo,
|
||||||
|
PreparedTrack,
|
||||||
} from './container-classes.js';
|
} from './container-classes.js';
|
||||||
|
|
||||||
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
|
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
|
||||||
|
|
@ -215,7 +216,11 @@ 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.includes('video') || lowType.includes('track')) {
|
if (lowType.includes('video')) {
|
||||||
|
normalized = { ...track, type: 'video' };
|
||||||
|
} else if (lowType.includes('track')) {
|
||||||
|
normalized = { ...track, type: 'track' };
|
||||||
|
} else {
|
||||||
normalized = { ...track, type: lowType };
|
normalized = { ...track, type: lowType };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -231,7 +236,7 @@ export class LosslessAPI {
|
||||||
|
|
||||||
normalized.isUnavailable = isTrackUnavailable(normalized);
|
normalized.isUnavailable = isTrackUnavailable(normalized);
|
||||||
|
|
||||||
return normalized;
|
return normalized.type == 'video' ? new PreparedVideo(normalized) : new PreparedTrack(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
prepareAlbum(album) {
|
prepareAlbum(album) {
|
||||||
|
|
@ -639,7 +644,6 @@ export class LosslessAPI {
|
||||||
|
|
||||||
const response = await this.fetchWithRetry(`/video/?id=${id}`, {
|
const response = await this.fetchWithRetry(`/video/?id=${id}`, {
|
||||||
type: 'streaming',
|
type: 'streaming',
|
||||||
allowedDomains: ['api.monochrome.tf', 'arran.monochrome.tf'],
|
|
||||||
});
|
});
|
||||||
const jsonResponse = await response.json();
|
const jsonResponse = await response.json();
|
||||||
|
|
||||||
|
|
@ -782,13 +786,13 @@ export class LosslessAPI {
|
||||||
|
|
||||||
tracks = tracks.map((t) => {
|
tracks = tracks.map((t) => {
|
||||||
if (t.album) {
|
if (t.album) {
|
||||||
t.album = Object.assign(new TrackAlbum(), t.album);
|
t.album = new TrackAlbum(t.album);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.assign(new Track(), t);
|
return new Track(t);
|
||||||
});
|
});
|
||||||
|
|
||||||
album = Object.assign(new Album(), album);
|
album = new Album(album);
|
||||||
|
|
||||||
const result = { album, tracks };
|
const result = { album, tracks };
|
||||||
|
|
||||||
|
|
@ -904,10 +908,10 @@ export class LosslessAPI {
|
||||||
|
|
||||||
tracks = tracks.map((t) => {
|
tracks = tracks.map((t) => {
|
||||||
if (t.album) {
|
if (t.album) {
|
||||||
t.album = Object.assign(new TrackAlbum(), t.album);
|
t.album = new TrackAlbum(t.album);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.assign(new Track(), t);
|
return new Track(t);
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = { playlist, tracks };
|
const result = { playlist, tracks };
|
||||||
|
|
@ -940,10 +944,10 @@ export class LosslessAPI {
|
||||||
|
|
||||||
tracks = tracks.map((t) => {
|
tracks = tracks.map((t) => {
|
||||||
if (t.album) {
|
if (t.album) {
|
||||||
t.album = Object.assign(new TrackAlbum(), t.album);
|
t.album = new TrackAlbum(t.album);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.assign(new Track(), t);
|
return new Track(t);
|
||||||
});
|
});
|
||||||
|
|
||||||
const mix = {
|
const mix = {
|
||||||
|
|
@ -1468,7 +1472,7 @@ export class LosslessAPI {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStreamUrl(id, quality = 'HI_RES_LOSSLESS') {
|
async getStreamUrl(id, quality = 'HI_RES_LOSSLESS', download = false) {
|
||||||
const cacheKey = `stream_info_${id}_${quality}`;
|
const cacheKey = `stream_info_${id}_${quality}`;
|
||||||
|
|
||||||
if (this.streamCache.has(cacheKey)) {
|
if (this.streamCache.has(cacheKey)) {
|
||||||
|
|
@ -1515,7 +1519,7 @@ export class LosslessAPI {
|
||||||
paramsArray.push(['formats', 'AACLC']);
|
paramsArray.push(['formats', 'AACLC']);
|
||||||
paramsArray.push(['formats', 'FLAC_HIRES']);
|
paramsArray.push(['formats', 'FLAC_HIRES']);
|
||||||
paramsArray.push(['formats', 'FLAC']);
|
paramsArray.push(['formats', 'FLAC']);
|
||||||
} else if (quality === 'DOLBY_ATMOS' && canPlayAtmos) {
|
} else if (quality === 'DOLBY_ATMOS' && (canPlayAtmos || download)) {
|
||||||
paramsArray.push(['formats', 'EAC3_JOC']);
|
paramsArray.push(['formats', 'EAC3_JOC']);
|
||||||
} else {
|
} else {
|
||||||
// Default fallback or "auto" behavior
|
// Default fallback or "auto" behavior
|
||||||
|
|
@ -1523,7 +1527,7 @@ export class LosslessAPI {
|
||||||
paramsArray.push(['formats', 'AACLC']);
|
paramsArray.push(['formats', 'AACLC']);
|
||||||
paramsArray.push(['formats', 'FLAC']);
|
paramsArray.push(['formats', 'FLAC']);
|
||||||
paramsArray.push(['formats', 'FLAC_HIRES']);
|
paramsArray.push(['formats', 'FLAC_HIRES']);
|
||||||
if (canPlayAtmos) {
|
if (canPlayAtmos || download) {
|
||||||
paramsArray.push(['formats', 'EAC3_JOC']);
|
paramsArray.push(['formats', 'EAC3_JOC']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1636,6 +1640,10 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
async enrichTrack(input, { downloadQuality = 'HI_RES_LOSSLESS' }) {
|
async enrichTrack(input, { downloadQuality = 'HI_RES_LOSSLESS' }) {
|
||||||
|
if (downloadQuality == 'DOLBY_ATMOS' && !input?.audioModes?.includes('DOLBY_ATMOS')) {
|
||||||
|
downloadQuality = 'LOSSLESS';
|
||||||
|
}
|
||||||
|
|
||||||
const id = input?.id || input;
|
const id = input?.id || input;
|
||||||
const track = typeof input === 'object' ? input : await this.getTrack(id, downloadQuality);
|
const track = typeof input === 'object' ? input : await this.getTrack(id, downloadQuality);
|
||||||
const isVideo = track?.type?.toLowerCase().includes('video');
|
const isVideo = track?.type?.toLowerCase().includes('video');
|
||||||
|
|
@ -1645,7 +1653,7 @@ export class LosslessAPI {
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
lookup = await this.getVideo(id);
|
lookup = await this.getVideo(id);
|
||||||
} else {
|
} else {
|
||||||
lookup = Object.assign(new PlaybackInfo(), await this.getTrack(id, downloadQuality));
|
lookup = new PlaybackInfo(await this.getTrack(id, downloadQuality));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input instanceof EnrichedTrack) {
|
if (input instanceof EnrichedTrack) {
|
||||||
|
|
@ -1656,9 +1664,9 @@ export class LosslessAPI {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const enrichedTrack = { ...track };
|
const enrichedTrack = { ...this.prepareTrack(track) };
|
||||||
if (lookup.info) {
|
if (lookup.info) {
|
||||||
enrichedTrack.replayGain = Object.assign(new ReplayGain(), {
|
enrichedTrack.replayGain = new ReplayGain({
|
||||||
trackReplayGain: lookup.info.trackReplayGain,
|
trackReplayGain: lookup.info.trackReplayGain,
|
||||||
trackPeakAmplitude: lookup.info.trackPeakAmplitude,
|
trackPeakAmplitude: lookup.info.trackPeakAmplitude,
|
||||||
albumReplayGain: lookup.info.albumReplayGain,
|
albumReplayGain: lookup.info.albumReplayGain,
|
||||||
|
|
@ -1669,7 +1677,7 @@ export class LosslessAPI {
|
||||||
if (track.album?.id && (track.album?.totalDiscs == null || track.album?.numberOfTracksOnDisc == null)) {
|
if (track.album?.id && (track.album?.totalDiscs == null || track.album?.numberOfTracksOnDisc == null)) {
|
||||||
try {
|
try {
|
||||||
const albumData = await this.getAlbum(track.album.id);
|
const albumData = await this.getAlbum(track.album.id);
|
||||||
enrichedTrack.album = Object.assign(new EnrichedAlbum(), {
|
enrichedTrack.album = new EnrichedAlbum({
|
||||||
...albumData.album,
|
...albumData.album,
|
||||||
...enrichedTrack.album,
|
...enrichedTrack.album,
|
||||||
});
|
});
|
||||||
|
|
@ -1684,7 +1692,7 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
const totalDiscs = maxDiscNumber || 1;
|
const totalDiscs = maxDiscNumber || 1;
|
||||||
const discNumber = getTrackDiscNumber(track);
|
const discNumber = getTrackDiscNumber(track);
|
||||||
enrichedTrack.album = Object.assign(new EnrichedAlbum(), {
|
enrichedTrack.album = new EnrichedAlbum({
|
||||||
...(enrichedTrack.album || {}),
|
...(enrichedTrack.album || {}),
|
||||||
|
|
||||||
totalDiscs: track.album?.totalDiscs ?? totalDiscs,
|
totalDiscs: track.album?.totalDiscs ?? totalDiscs,
|
||||||
|
|
@ -1697,10 +1705,10 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(enrichedTrack.album instanceof EnrichedAlbum)) {
|
if (!(enrichedTrack.album instanceof EnrichedAlbum)) {
|
||||||
enrichedTrack.album = Object.assign(new TrackAlbum(), enrichedTrack.album);
|
enrichedTrack.album = new TrackAlbum(enrichedTrack.album);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { lookup, enrichedTrack: Object.assign(new EnrichedTrack(), enrichedTrack), isVideo };
|
return { lookup, enrichedTrack: new EnrichedTrack(enrichedTrack), isVideo };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1726,7 +1734,7 @@ export class LosslessAPI {
|
||||||
*
|
*
|
||||||
* @throws {Error} If stream URL cannot be resolved, manifest is missing, or download fails
|
* @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 {AbortError} If the download is aborted via the signal
|
||||||
* @throws {MP3EncodingError|FfmpegError} If audio transcoding fails
|
* @throws {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.
|
||||||
|
|
@ -1739,7 +1747,7 @@ export class LosslessAPI {
|
||||||
|
|
||||||
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;
|
let downloadQuality = isCustomFormat(quality) ? 'LOSSLESS' : quality;
|
||||||
|
|
||||||
const { lookup, enrichedTrack, isVideo } = await this.enrichTrack(track, { downloadQuality });
|
const { lookup, enrichedTrack, isVideo } = await this.enrichTrack(track, { downloadQuality });
|
||||||
|
|
||||||
|
|
@ -1769,11 +1777,24 @@ export class LosslessAPI {
|
||||||
throw new Error('Could not resolve manifest');
|
throw new Error('Could not resolve manifest');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (preferDolbyAtmosSettings.isEnabled() && track.audioModes?.includes('DOLBY_ATMOS')) {
|
||||||
|
try {
|
||||||
|
const stream = await this.getStreamUrl(id, 'DOLBY_ATMOS', true);
|
||||||
|
const manifest = await fetch(stream.url, { signal: options.signal });
|
||||||
|
const manifestText = await manifest.text();
|
||||||
|
streamUrl = this.extractStreamUrlFromManifest(btoa(manifestText));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to extract Dolby Atmos stream URL:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!streamUrl) {
|
||||||
streamUrl = this.extractStreamUrlFromManifest(manifest);
|
streamUrl = this.extractStreamUrlFromManifest(manifest);
|
||||||
if (!streamUrl) {
|
if (!streamUrl) {
|
||||||
throw new Error('Could not resolve stream URL');
|
throw new Error('Could not resolve stream URL');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle DASH streams (blob URLs)
|
// Handle DASH streams (blob URLs)
|
||||||
if (streamUrl.startsWith('blob:')) {
|
if (streamUrl.startsWith('blob:')) {
|
||||||
|
|
@ -1877,7 +1898,15 @@ export class LosslessAPI {
|
||||||
try {
|
try {
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
blob = new File(
|
blob = new File(
|
||||||
[await ffmpeg(blob, ['-c', 'copy'], 'output.mp4', 'video/mp4', onProgress, options.signal)],
|
[
|
||||||
|
await ffmpeg(blob, {
|
||||||
|
args: ['-c', 'copy'],
|
||||||
|
outputName: 'output.mp4',
|
||||||
|
outputMime: 'video/mp4',
|
||||||
|
onProgress,
|
||||||
|
signal: options.signal,
|
||||||
|
}),
|
||||||
|
],
|
||||||
'output.mp4',
|
'output.mp4',
|
||||||
{ type: 'video/mp4' }
|
{ type: 'video/mp4' }
|
||||||
);
|
);
|
||||||
|
|
@ -1908,11 +1937,7 @@ export class LosslessAPI {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
console.error('Download failed:', error);
|
console.error('Download failed:', error);
|
||||||
if (
|
if (error instanceof FfmpegError || error.code === 'MP3_ENCODING_FAILED') {
|
||||||
error instanceof MP3EncodingError ||
|
|
||||||
error instanceof FfmpegError ||
|
|
||||||
error.code === 'MP3_ENCODING_FAILED'
|
|
||||||
) {
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (error.message === RATE_LIMIT_ERROR_MESSAGE) {
|
if (error.message === RATE_LIMIT_ERROR_MESSAGE) {
|
||||||
|
|
|
||||||
12
js/app.js
12
js/app.js
|
|
@ -1215,8 +1215,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { mix, tracks } = await MusicAPI.instance.getMix(mixId);
|
const { mix, tracks } = await MusicAPI.instance.getMix(mixId);
|
||||||
const { downloadPlaylistAsZip } = await loadDownloadsModule();
|
const { downloadPlaylist } = await loadDownloadsModule();
|
||||||
await downloadPlaylistAsZip(
|
await downloadPlaylist(
|
||||||
mix,
|
mix,
|
||||||
tracks,
|
tracks,
|
||||||
MusicAPI.instance,
|
MusicAPI.instance,
|
||||||
|
|
@ -1264,8 +1264,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
tracks = data.tracks;
|
tracks = data.tracks;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { downloadPlaylistAsZip } = await loadDownloadsModule();
|
const { downloadPlaylist } = await loadDownloadsModule();
|
||||||
await downloadPlaylistAsZip(
|
await downloadPlaylist(
|
||||||
playlist,
|
playlist,
|
||||||
tracks,
|
tracks,
|
||||||
MusicAPI.instance,
|
MusicAPI.instance,
|
||||||
|
|
@ -2181,8 +2181,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { album, tracks } = await MusicAPI.instance.getAlbum(albumId);
|
const { album, tracks } = await MusicAPI.instance.getAlbum(albumId);
|
||||||
const { downloadAlbumAsZip } = await loadDownloadsModule();
|
const { downloadAlbum } = await loadDownloadsModule();
|
||||||
await downloadAlbumAsZip(
|
await downloadAlbum(
|
||||||
album,
|
album,
|
||||||
tracks,
|
tracks,
|
||||||
MusicAPI.instance,
|
MusicAPI.instance,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,22 @@
|
||||||
export class ReplayGain {
|
export class BaseContainer<T extends object = object> {
|
||||||
|
constructor(data: T) {
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReplayGain extends BaseContainer {
|
||||||
trackReplayGain: number;
|
trackReplayGain: number;
|
||||||
albumReplayGain: number;
|
albumReplayGain: number;
|
||||||
trackPeakAmplitude: number;
|
trackPeakAmplitude: number;
|
||||||
albumPeakAmplitude: number;
|
albumPeakAmplitude: number;
|
||||||
|
|
||||||
|
constructor(data: object) {
|
||||||
|
super(data);
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Track {
|
export class Track extends BaseContainer {
|
||||||
accessType: string;
|
accessType: string;
|
||||||
adSupportedStreamReady: boolean;
|
adSupportedStreamReady: boolean;
|
||||||
album: TrackAlbum;
|
album: TrackAlbum;
|
||||||
|
|
@ -40,6 +51,11 @@ export class Track {
|
||||||
url: string;
|
url: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
volumeNumber: number;
|
volumeNumber: number;
|
||||||
|
|
||||||
|
constructor(data: object) {
|
||||||
|
super(data);
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PlaybackInfo extends ReplayGain {
|
export class PlaybackInfo extends ReplayGain {
|
||||||
|
|
@ -52,31 +68,56 @@ export class PlaybackInfo extends ReplayGain {
|
||||||
manifest: string;
|
manifest: string;
|
||||||
bitDepth: number;
|
bitDepth: number;
|
||||||
sampleRate: number;
|
sampleRate: number;
|
||||||
|
|
||||||
|
constructor(data: object) {
|
||||||
|
super(data);
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MediaMetadata {
|
export class MediaMetadata extends BaseContainer {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
|
||||||
|
constructor(data: object) {
|
||||||
|
super(data);
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Artist {
|
export class Artist extends BaseContainer {
|
||||||
handle: any;
|
handle: any;
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
picture: string;
|
picture: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
|
||||||
|
constructor(data: object) {
|
||||||
|
super(data);
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EnrichedTrack extends Track {
|
export class EnrichedTrack extends Track {
|
||||||
declare album: TrackAlbum | EnrichedAlbum;
|
declare album: TrackAlbum | EnrichedAlbum;
|
||||||
declare replayGain: any | ReplayGain;
|
declare replayGain: any | ReplayGain;
|
||||||
|
|
||||||
|
constructor(data: object) {
|
||||||
|
super(data);
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TrackAlbum {
|
export class TrackAlbum extends BaseContainer {
|
||||||
cover: string;
|
cover: string;
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
vibrantColor: string;
|
vibrantColor: string;
|
||||||
videoCover?: string;
|
videoCover?: string;
|
||||||
|
|
||||||
|
constructor(data: object) {
|
||||||
|
super(data);
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Album extends TrackAlbum {
|
export class Album extends TrackAlbum {
|
||||||
|
|
@ -105,9 +146,48 @@ export class Album extends TrackAlbum {
|
||||||
upload: boolean;
|
upload: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
|
|
||||||
|
constructor(data: object) {
|
||||||
|
super(data);
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EnrichedAlbum extends Album {
|
export class EnrichedAlbum extends Album {
|
||||||
totalDiscs?: number;
|
totalDiscs?: number;
|
||||||
numberOfTracksOnDisc?: number;
|
numberOfTracksOnDisc?: number;
|
||||||
|
|
||||||
|
constructor(data: object) {
|
||||||
|
super(data);
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PreparedItem extends BaseContainer {
|
||||||
|
constructor(data: object) {
|
||||||
|
super(data);
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class PreparedTrack extends PreparedItem {
|
||||||
|
type: 'track';
|
||||||
|
|
||||||
|
constructor(data: object) {
|
||||||
|
super(data);
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class PreparedAlbum extends PreparedItem {
|
||||||
|
constructor(data: object) {
|
||||||
|
super(data);
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class PreparedVideo extends PreparedItem {
|
||||||
|
type: 'video';
|
||||||
|
|
||||||
|
constructor(data: object) {
|
||||||
|
super(data);
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
getContainerFormat,
|
getContainerFormat,
|
||||||
transcodeWithContainerFormat,
|
transcodeWithContainerFormat,
|
||||||
} from './ffmpegFormats';
|
} from './ffmpegFormats';
|
||||||
import { ffmpegNewContainer } from './ffmpeg';
|
import { ffmpegInfo, ffmpegNewContainer } from './ffmpeg';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Triggers a browser file download for the given blob.
|
* Triggers a browser file download for the given blob.
|
||||||
|
|
@ -72,15 +72,22 @@ export async function applyAudioPostProcessing(
|
||||||
trackAudioQuality: string | null = null
|
trackAudioQuality: string | null = null
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
const extension = await getExtensionFromBlob(blob);
|
const extension = await getExtensionFromBlob(blob);
|
||||||
|
const statedLossless = (trackAudioQuality || quality).endsWith('LOSSLESS');
|
||||||
|
|
||||||
// Determine whether the downloaded source is lossless.
|
// Determine whether the downloaded source is lossless.
|
||||||
// FLAC is always lossless. m4a is lossless only when the track's
|
// FLAC is always lossless. m4a is lossless only when the track's
|
||||||
// audio quality from the API is LOSSLESS or HI_RES_LOSSLESS; otherwise
|
// audio quality from the API is LOSSLESS or HI_RES_LOSSLESS; otherwise
|
||||||
// it is AAC (lossy).
|
// it is AAC (lossy).
|
||||||
const sourceIsLossless =
|
let sourceIsLossless =
|
||||||
extension === 'flac' ||
|
extension === 'flac' ||
|
||||||
(extension === 'm4a' && (trackAudioQuality === 'LOSSLESS' || trackAudioQuality === 'HI_RES_LOSSLESS'));
|
(extension === 'm4a' && (trackAudioQuality === 'LOSSLESS' || trackAudioQuality === 'HI_RES_LOSSLESS'));
|
||||||
|
|
||||||
|
if (statedLossless && !sourceIsLossless) {
|
||||||
|
// Basic checks say the file isn't lossless, but we'll use ffmpegInfo to check the codec.
|
||||||
|
const ffmpegLog: string[] = await ffmpegInfo(blob);
|
||||||
|
sourceIsLossless = ffmpegLog.some((line) => line.match(/Stream #\d:\d -> #\d:\d \(flac/));
|
||||||
|
}
|
||||||
|
|
||||||
// Transcode to custom lossy format if requested
|
// Transcode to custom lossy format if requested
|
||||||
if (isCustomFormat(quality)) {
|
if (isCustomFormat(quality)) {
|
||||||
// If the source is already lossy, transcoding would degrade quality
|
// If the source is already lossy, transcoding would degrade quality
|
||||||
|
|
@ -104,7 +111,7 @@ export async function applyAudioPostProcessing(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quality.endsWith('LOSSLESS')) {
|
if (statedLossless) {
|
||||||
try {
|
try {
|
||||||
const containerName = losslessContainerSettings.getContainer();
|
const containerName = losslessContainerSettings.getContainer();
|
||||||
const containerFmt = getContainerFormat(containerName);
|
const containerFmt = getContainerFormat(containerName);
|
||||||
|
|
|
||||||
|
|
@ -682,7 +682,7 @@ export async function downloadTracks(tracks, api, quality, lyricsManager = null)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsManager = null) {
|
export async function downloadAlbum(album, tracks, api, quality, lyricsManager = null) {
|
||||||
const releaseDateStr =
|
const releaseDateStr =
|
||||||
album.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : '');
|
album.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : '');
|
||||||
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
||||||
|
|
@ -707,7 +707,7 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyricsManager = null) {
|
export async function downloadPlaylist(playlist, tracks, api, quality, lyricsManager = null) {
|
||||||
const folderName = formatPathTemplate(modernSettings.folderTemplate, {
|
const folderName = formatPathTemplate(modernSettings.folderTemplate, {
|
||||||
albumTitle: playlist.title,
|
albumTitle: playlist.title,
|
||||||
albumArtist: 'Playlist',
|
albumArtist: 'Playlist',
|
||||||
|
|
@ -939,11 +939,32 @@ function createBulkDownloadNotification(type, name, _totalItems) {
|
||||||
return notifEl;
|
return notifEl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} notifEl
|
||||||
|
* @param {number} current
|
||||||
|
* @param {number} total
|
||||||
|
* @param {string} currentItem
|
||||||
|
* @param {FfmpegProgress | ProgressMessage | null} progress
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
function updateBulkDownloadProgress(notifEl, current, total, currentItem, progress = null) {
|
function updateBulkDownloadProgress(notifEl, current, total, currentItem, progress = null) {
|
||||||
|
/** @type {HTMLElement | null} */
|
||||||
const progressFill = notifEl.querySelector('.download-progress-fill');
|
const progressFill = notifEl.querySelector('.download-progress-fill');
|
||||||
|
|
||||||
|
/** @type {HTMLElement | null} */
|
||||||
const statusEl = notifEl.querySelector('.download-status');
|
const statusEl = notifEl.querySelector('.download-status');
|
||||||
|
|
||||||
|
if (!progressFill || !statusEl) {
|
||||||
|
console.log('Progress elements not found in notification');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (progress instanceof FfmpegProgress) {
|
if (progress instanceof FfmpegProgress) {
|
||||||
|
if (progress.stage == 'stdout') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const percent = progress.progress || 0;
|
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
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
waveformSettings,
|
waveformSettings,
|
||||||
keyboardShortcuts,
|
keyboardShortcuts,
|
||||||
} from './storage.js';
|
} from './storage.js';
|
||||||
import { showNotification, downloadTrackWithMetadata, downloadAlbumAsZip, downloadPlaylistAsZip } from './downloads.js';
|
import { showNotification, downloadTrackWithMetadata, downloadAlbum, downloadPlaylist } from './downloads.js';
|
||||||
import { downloadQualitySettings } from './storage.js';
|
import { downloadQualitySettings } from './storage.js';
|
||||||
import { updateTabTitle, navigate } from './router.js';
|
import { updateTabTitle, navigate } from './router.js';
|
||||||
import { db } from './db.js';
|
import { db } from './db.js';
|
||||||
|
|
@ -1238,7 +1238,7 @@ export async function handleTrackAction(
|
||||||
|
|
||||||
if (action === 'download') {
|
if (action === 'download') {
|
||||||
if (type === 'album') {
|
if (type === 'album') {
|
||||||
await downloadAlbumAsZip(
|
await downloadAlbum(
|
||||||
collectionItem,
|
collectionItem,
|
||||||
tracks,
|
tracks,
|
||||||
api,
|
api,
|
||||||
|
|
@ -1246,7 +1246,7 @@ export async function handleTrackAction(
|
||||||
lyricsManager
|
lyricsManager
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await downloadPlaylistAsZip(
|
await downloadPlaylist(
|
||||||
collectionItem,
|
collectionItem,
|
||||||
tracks,
|
tracks,
|
||||||
api,
|
api,
|
||||||
|
|
|
||||||
85
js/ffmpeg.js
85
js/ffmpeg.js
|
|
@ -38,6 +38,7 @@ export function loadFfmpeg() {
|
||||||
* @param {(progress: 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
|
||||||
|
* @param {Boolean} logConsole - Whether to log FFmpeg output to the console
|
||||||
* @returns {Promise<Blob>} Encoded audio blob
|
* @returns {Promise<Blob>} Encoded audio blob
|
||||||
*/
|
*/
|
||||||
async function ffmpegWorker(
|
async function ffmpegWorker(
|
||||||
|
|
@ -47,7 +48,8 @@ async function ffmpegWorker(
|
||||||
outputMime = 'application/octet-stream',
|
outputMime = 'application/octet-stream',
|
||||||
onProgress = null,
|
onProgress = null,
|
||||||
signal = null,
|
signal = null,
|
||||||
extraFiles = []
|
extraFiles = [],
|
||||||
|
logConsole = true
|
||||||
) {
|
) {
|
||||||
const audioData = audioBlob ? await audioBlob.arrayBuffer() : null;
|
const audioData = audioBlob ? await audioBlob.arrayBuffer() : null;
|
||||||
const assets = loadFfmpeg();
|
const assets = loadFfmpeg();
|
||||||
|
|
@ -85,8 +87,11 @@ async function ffmpegWorker(
|
||||||
} else if (type === 'progress' && stage != 'loading' && progress !== null) {
|
} else if (type === 'progress' && stage != 'loading' && progress !== null) {
|
||||||
onProgress?.(new FfmpegProgress(stage, progress || 0, message));
|
onProgress?.(new FfmpegProgress(stage, progress || 0, message));
|
||||||
} else if (type === 'log') {
|
} else if (type === 'log') {
|
||||||
|
onProgress?.(new FfmpegProgress('stdout', 0, message));
|
||||||
|
if (logConsole) {
|
||||||
console.log('[FFmpeg]', message);
|
console.log('[FFmpeg]', message);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
worker.onerror = (error) => {
|
worker.onerror = (error) => {
|
||||||
|
|
@ -127,29 +132,43 @@ async function ffmpegWorker(
|
||||||
* Encodes audio using FFmpeg via Web Worker
|
* Encodes audio using FFmpeg via Web Worker
|
||||||
* @async
|
* @async
|
||||||
* @param {Blob} audioBlob - The audio blob to encode
|
* @param {Blob} audioBlob - The audio blob to encode
|
||||||
* @param {string[]} [args=[]] - FFmpeg command-line arguments
|
* @param {Object} [opts] - Options for FFmpeg encoding
|
||||||
* @param {string} [outputName='output'] - Name of the output file
|
* @param {string[]} [opts.args=[]] - FFmpeg command-line arguments
|
||||||
* @param {string} [outputMime='application/octet-stream'] - MIME type of the output
|
* @param {string} [opts.outputName='output'] - Name of the output file
|
||||||
* @param {(progress: FfmpegProgress) => void} [onProgress=null] - Optional callback for progress updates
|
* @param {string} [opts.outputMime='application/octet-stream'] - MIME type of the output
|
||||||
* @param {AbortSignal|null} [signal=null] - Optional abort signal to cancel encoding
|
* @param {(progress: FfmpegProgress) => void} [opts.onProgress=null] - Optional callback for progress updates
|
||||||
* @param {Array} [extraFiles=[]] - Additional files to provide to FFmpeg
|
* @param {AbortSignal|null} [opts.signal=null] - Optional abort signal to cancel encoding
|
||||||
|
* @param {Array} [opts.extraFiles=[]] - Additional files to provide to FFmpeg
|
||||||
|
* @param {Boolean} [opts.logConsole=true] - Whether to log FFmpeg output to the console
|
||||||
* @returns {Promise<Blob>} Encoded audio blob
|
* @returns {Promise<Blob>} Encoded audio blob
|
||||||
* @throws {FfmpegError} If Web Workers are not available
|
* @throws {FfmpegError} If Web Workers are not available
|
||||||
* @throws {Error} If FFmpeg encoding fails
|
* @throws {Error} If FFmpeg encoding fails
|
||||||
*/
|
*/
|
||||||
export async function ffmpeg(
|
export async function ffmpeg(
|
||||||
audioBlob,
|
audioBlob,
|
||||||
|
{
|
||||||
args = [],
|
args = [],
|
||||||
outputName = 'output',
|
outputName = 'output',
|
||||||
outputMime = 'application/octet-stream',
|
outputMime = 'application/octet-stream',
|
||||||
onProgress = null,
|
onProgress = null,
|
||||||
signal = null,
|
signal = null,
|
||||||
extraFiles = []
|
extraFiles = [],
|
||||||
|
logConsole = true,
|
||||||
|
} = {}
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Use Web Worker for non-blocking FFmpeg encoding
|
// Use Web Worker for non-blocking FFmpeg encoding
|
||||||
if (typeof Worker !== 'undefined') {
|
if (typeof Worker !== 'undefined') {
|
||||||
return await ffmpegWorker(audioBlob, args, outputName, outputMime, onProgress, signal, extraFiles);
|
return await ffmpegWorker(
|
||||||
|
audioBlob,
|
||||||
|
args,
|
||||||
|
outputName,
|
||||||
|
outputMime,
|
||||||
|
onProgress,
|
||||||
|
signal,
|
||||||
|
extraFiles,
|
||||||
|
logConsole
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new FfmpegError('Web Workers are required for FFMPEG');
|
throw new FfmpegError('Web Workers are required for FFMPEG');
|
||||||
|
|
@ -159,24 +178,56 @@ export async function ffmpeg(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves information about an audio blob using FFmpeg
|
||||||
|
* @param {Blob} audioBlob - The audio blob to analyze
|
||||||
|
* @param {Object} [options] - Options for FFmpeg info extraction
|
||||||
|
* @param {((progress: FfmpegProgress) => void) | null} [options.onProgress] - Callback function to track conversion progress
|
||||||
|
* @param {AbortSignal|null} [options.signal] - AbortSignal for cancelling the operation
|
||||||
|
* @returns {Promise<string[]>} A promise that resolves to an array of output lines
|
||||||
|
*/
|
||||||
|
export async function ffmpegInfo(audioBlob, { onProgress = null, signal = null } = {}) {
|
||||||
|
const outputLines = [];
|
||||||
|
try {
|
||||||
|
await ffmpeg(audioBlob, {
|
||||||
|
args: ['-t', '0.01'],
|
||||||
|
outputName: 'output.wav',
|
||||||
|
onProgress: (progress) => {
|
||||||
|
if (progress.stage === 'stdout' && progress.message) {
|
||||||
|
outputLines.push(progress.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.(progress);
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
logConsole: false,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof FfmpegError && !err.message.startsWith('Failed to delete')) {
|
||||||
|
console.warn('FFmpeg info extraction failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputLines;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new FFmpeg container with copied codec and stripped metadata.
|
* Creates a new FFmpeg container with copied codec and stripped metadata.
|
||||||
* @param {Blob} audioBlob - The audio blob to process
|
* @param {Blob} audioBlob - The audio blob to process
|
||||||
* @param {string} outputExtension - The extension for the output file
|
* @param {string} outputExtension - The extension for the output file
|
||||||
* @param {string} outputMime - The MIME type for the output blob
|
* @param {string} outputMime - The MIME type for the output blob
|
||||||
* @param {Function} onProgress - Callback function to track conversion progress
|
* @param {((progress: FfmpegProgress) => void) | null} onProgress - Callback function to track conversion progress
|
||||||
* @param {AbortSignal} signal - AbortSignal for cancelling the operation
|
* @param {AbortSignal} signal - AbortSignal for cancelling the operation
|
||||||
* @returns {Promise<Blob>} A promise that resolves to the processed data blob
|
* @returns {Promise<Blob>} A promise that resolves to the processed data blob
|
||||||
*/
|
*/
|
||||||
export async function ffmpegNewContainer(audioBlob, outputExtension, outputMime, onProgress, signal) {
|
export async function ffmpegNewContainer(audioBlob, outputExtension, outputMime, onProgress, signal) {
|
||||||
return await ffmpeg(
|
return await ffmpeg(audioBlob, {
|
||||||
audioBlob,
|
args: ['-map_metadata', '-1', '-c', 'copy', '-strict', '-2'],
|
||||||
['-map_metadata', '-1', '-c', 'copy', '-strict', '-2'],
|
outputName: `output.${outputExtension}`,
|
||||||
`output.${outputExtension}`,
|
outputMime: outputMime,
|
||||||
outputMime,
|
|
||||||
onProgress,
|
onProgress,
|
||||||
signal
|
signal: signal,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { FfmpegError };
|
export { FfmpegError };
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export class FfmpegProgress implements MonochromeProgress {
|
export class FfmpegProgress implements MonochromeProgress {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly stage: 'loading' | 'encoding' | 'finalizing',
|
public readonly stage: 'loading' | 'encoding' | 'finalizing' | 'stdout',
|
||||||
public readonly progress: number,
|
public readonly progress: number,
|
||||||
public readonly message?: string
|
public readonly message?: string
|
||||||
) {}
|
) {}
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ self.onmessage = async (e) => {
|
||||||
await ffmpeg.writeFile(file.name, new Uint8Array(file.data));
|
await ffmpeg.writeFile(file.name, new Uint8Array(file.data));
|
||||||
}
|
}
|
||||||
|
|
||||||
const ffmpegArgs = ['-i', 'input', ...args, output.name];
|
const ffmpegArgs = ['-i', 'input', ...args, ...(output.name ? [output.name] : [])];
|
||||||
self.postMessage({ type: 'log', message: `FFmpeg command: ffmpeg ${ffmpegArgs.join(' ')}` });
|
self.postMessage({ type: 'log', message: `FFmpeg command: ffmpeg ${ffmpegArgs.join(' ')}` });
|
||||||
|
|
||||||
const exitCode = await ffmpeg.exec(ffmpegArgs);
|
const exitCode = await ffmpeg.exec(ffmpegArgs);
|
||||||
|
|
@ -134,7 +134,7 @@ self.onmessage = async (e) => {
|
||||||
|
|
||||||
self.postMessage({ type: 'progress', stage: 'finalizing', message: encodeEndMessage, progress: 100.0 });
|
self.postMessage({ type: 'progress', stage: 'finalizing', message: encodeEndMessage, progress: 100.0 });
|
||||||
|
|
||||||
const data = await ffmpeg.readFile(output.name);
|
const data = output.name ? await ffmpeg.readFile(output.name) : [];
|
||||||
const outputBlob = new Blob([data], { type: output.mime });
|
const outputBlob = new Blob([data], { type: output.mime });
|
||||||
|
|
||||||
self.postMessage({ type: 'complete', blob: outputBlob });
|
self.postMessage({ type: 'complete', blob: outputBlob });
|
||||||
|
|
@ -152,7 +152,9 @@ self.onmessage = async (e) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
if (output.name) {
|
||||||
await ffmpeg.deleteFile(output.name);
|
await ffmpeg.deleteFile(output.name);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
self.postMessage({ type: 'log', message: `Failed to delete ${output.name} from FFmpeg FS.` });
|
self.postMessage({ type: 'log', message: `Failed to delete ${output.name} from FFmpeg FS.` });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -194,15 +194,14 @@ export async function transcodeWithCustomFormat(
|
||||||
signal: AbortSignal | null = null,
|
signal: AbortSignal | null = null,
|
||||||
extraFiles: any[] = []
|
extraFiles: any[] = []
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
return ffmpeg(
|
return ffmpeg(audioBlob, {
|
||||||
audioBlob,
|
args: format.ffmpegArgs,
|
||||||
format.ffmpegArgs,
|
outputName: format.outputFilename,
|
||||||
format.outputFilename,
|
outputMime: format.outputMime,
|
||||||
format.outputMime,
|
|
||||||
onProgress,
|
onProgress,
|
||||||
signal,
|
signal,
|
||||||
extraFiles
|
extraFiles,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -216,13 +215,12 @@ export async function transcodeWithContainerFormat(
|
||||||
signal: AbortSignal | null = null,
|
signal: AbortSignal | null = null,
|
||||||
extraFiles: any[] = []
|
extraFiles: any[] = []
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
return ffmpeg(
|
return ffmpeg(audioBlob, {
|
||||||
audioBlob,
|
args: format.ffmpegArgs,
|
||||||
format.ffmpegArgs,
|
outputName: format.outputFilename,
|
||||||
format.outputFilename,
|
outputMime: format.outputMime,
|
||||||
format.outputMime,
|
|
||||||
onProgress,
|
onProgress,
|
||||||
signal,
|
signal,
|
||||||
extraFiles
|
extraFiles,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { ffmpeg } from './ffmpeg';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {import('./ffmpeg.types.ts').FfmpegProgress} FfmpegProgress
|
|
||||||
*/
|
|
||||||
|
|
||||||
class MP3EncodingError extends Error {
|
|
||||||
constructor(message) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'MP3EncodingError';
|
|
||||||
this.code = 'MP3_ENCODING_FAILED';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {Blob} audioBlob
|
|
||||||
* @param {(progress: 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) {
|
|
||||||
try {
|
|
||||||
// Use Web Worker for non-blocking FFmpeg encoding
|
|
||||||
if (typeof Worker !== 'undefined') {
|
|
||||||
const args = ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '320k', '-ar', '44100'];
|
|
||||||
|
|
||||||
return await ffmpeg(audioBlob, { args }, 'output.mp3', 'audio/mpeg', onProgress, signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new MP3EncodingError('Web Workers are required for MP3 encoding');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('MP3 encoding failed:', error);
|
|
||||||
|
|
||||||
throw new MP3EncodingError(error?.message ?? error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { MP3EncodingError };
|
|
||||||
|
|
@ -493,7 +493,10 @@ export class Player {
|
||||||
const isPodcast = track.isPodcast || (track.id && String(track.id).startsWith('podcast_'));
|
const isPodcast = track.isPodcast || (track.id && String(track.id).startsWith('podcast_'));
|
||||||
if (track.isLocal || isTracker || isPodcast || (track.audioUrl && !track.isLocal)) continue;
|
if (track.isLocal || isTracker || isPodcast || (track.audioUrl && !track.isLocal)) continue;
|
||||||
try {
|
try {
|
||||||
const streamInfo = await this.api.getStreamUrl(track.id, this.quality);
|
const streamInfo =
|
||||||
|
track.type == 'video'
|
||||||
|
? await this.api.getVideoStreamUrl(track.id)
|
||||||
|
: await this.api.getStreamUrl(track.id, this.quality);
|
||||||
|
|
||||||
if (this.preloadAbortController.signal.aborted) break;
|
if (this.preloadAbortController.signal.aborted) break;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import {
|
||||||
gaplessPlaybackSettings,
|
gaplessPlaybackSettings,
|
||||||
analyticsSettings,
|
analyticsSettings,
|
||||||
modalSettings,
|
modalSettings,
|
||||||
|
preferDolbyAtmosSettings,
|
||||||
} from './storage.js';
|
} from './storage.js';
|
||||||
import { audioContextManager, EQ_PRESETS } from './audio-context.js';
|
import { audioContextManager, EQ_PRESETS } from './audio-context.js';
|
||||||
import { db } from './db.js';
|
import { db } from './db.js';
|
||||||
|
|
@ -813,7 +814,6 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
||||||
if (downloadQualitySetting) {
|
if (downloadQualitySetting) {
|
||||||
// Assign categories to the static (native) options already in the HTML
|
// Assign categories to the static (native) options already in the HTML
|
||||||
const staticCategories = {
|
const staticCategories = {
|
||||||
DOLBY_ATMOS: 'Spatial',
|
|
||||||
HI_RES_LOSSLESS: 'Lossless',
|
HI_RES_LOSSLESS: 'Lossless',
|
||||||
LOSSLESS: 'Lossless',
|
LOSSLESS: 'Lossless',
|
||||||
HIGH: 'AAC',
|
HIGH: 'AAC',
|
||||||
|
|
@ -840,7 +840,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
||||||
const m = text.match(/(\d+)\s*kbps/i);
|
const m = text.match(/(\d+)\s*kbps/i);
|
||||||
return m ? parseInt(m[1], 10) : Infinity;
|
return m ? parseInt(m[1], 10) : Infinity;
|
||||||
};
|
};
|
||||||
const categoryOrder = ['Spatial', 'Lossless', 'AAC', 'MP3', 'OGG'];
|
const categoryOrder = ['Lossless', 'AAC', 'MP3', 'OGG'];
|
||||||
allOptions.sort((a, b) => {
|
allOptions.sort((a, b) => {
|
||||||
if (a.category == b.category && a.category === 'Lossless') return 0; // Preserve original order for lossless options
|
if (a.category == b.category && a.category === 'Lossless') return 0; // Preserve original order for lossless options
|
||||||
const ai = categoryOrder.indexOf(a.category);
|
const ai = categoryOrder.indexOf(a.category);
|
||||||
|
|
@ -878,6 +878,14 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prefersAtmosSetting = document.getElementById('dolby-atmos-toggle');
|
||||||
|
if (prefersAtmosSetting) {
|
||||||
|
prefersAtmosSetting.checked = preferDolbyAtmosSettings.isEnabled();
|
||||||
|
prefersAtmosSetting.addEventListener('change', (e) => {
|
||||||
|
preferDolbyAtmosSettings.setEnabled(e.target.checked);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const losslessContainerSetting = document.getElementById('lossless-container-setting');
|
const losslessContainerSetting = document.getElementById('lossless-container-setting');
|
||||||
const losslessContainerSettingItem = losslessContainerSetting?.closest('.setting-item');
|
const losslessContainerSettingItem = losslessContainerSetting?.closest('.setting-item');
|
||||||
|
|
||||||
|
|
@ -979,7 +987,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
||||||
if (resetSavedFolderSetting) {
|
if (resetSavedFolderSetting) {
|
||||||
let showReset = false;
|
let showReset = false;
|
||||||
if (isFolderMethod && hasFolderPicker && modernSettings.rememberBulkDownloadFolder) {
|
if (isFolderMethod && hasFolderPicker && modernSettings.rememberBulkDownloadFolder) {
|
||||||
const savedHandle = await db.getSetting('bulk_download_folder_handle');
|
const savedHandle = modernSettings.bulkDownloadFolder;
|
||||||
showReset = !!savedHandle;
|
showReset = !!savedHandle;
|
||||||
}
|
}
|
||||||
resetSavedFolderSetting.style.display = showReset ? '' : 'none';
|
resetSavedFolderSetting.style.display = showReset ? '' : 'none';
|
||||||
|
|
@ -1064,7 +1072,8 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
||||||
|
|
||||||
if (resetSavedFolderBtn) {
|
if (resetSavedFolderBtn) {
|
||||||
resetSavedFolderBtn.addEventListener('click', async () => {
|
resetSavedFolderBtn.addEventListener('click', async () => {
|
||||||
await db.saveSetting('bulk_download_folder_handle', null);
|
modernSettings.bulkDownloadFolder = null;
|
||||||
|
await modernSettings.waitPending();
|
||||||
await updateFolderMethodVisibility();
|
await updateFolderMethodVisibility();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1084,7 +1093,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
||||||
}
|
}
|
||||||
|
|
||||||
updateForceZipBlobVisibility();
|
updateForceZipBlobVisibility();
|
||||||
updateFolderMethodVisibility();
|
await updateFolderMethodVisibility();
|
||||||
|
|
||||||
const includeCoverToggle = document.getElementById('include-cover-toggle');
|
const includeCoverToggle = document.getElementById('include-cover-toggle');
|
||||||
if (includeCoverToggle) {
|
if (includeCoverToggle) {
|
||||||
|
|
@ -2581,6 +2590,23 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
||||||
observer.observe(appearanceTabContent, { attributes: true });
|
observer.observe(appearanceTabContent, { attributes: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Watch for downloads tab becoming active and update setting visibility
|
||||||
|
const downloadsTabContent = document.getElementById('settings-tab-downloads');
|
||||||
|
if (downloadsTabContent) {
|
||||||
|
const observer = new MutationObserver(async (mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
||||||
|
if (downloadsTabContent.classList.contains('active')) {
|
||||||
|
console.log('[Settings] Downloads tab became active, updating setting visibility');
|
||||||
|
updateForceZipBlobVisibility();
|
||||||
|
await updateFolderMethodVisibility();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(downloadsTabContent, { attributes: true });
|
||||||
|
}
|
||||||
|
|
||||||
// Visualizer Mode Select
|
// Visualizer Mode Select
|
||||||
const visualizerModeSelect = document.getElementById('visualizer-mode-select');
|
const visualizerModeSelect = document.getElementById('visualizer-mode-select');
|
||||||
if (visualizerModeSelect) {
|
if (visualizerModeSelect) {
|
||||||
|
|
|
||||||
|
|
@ -648,6 +648,14 @@ export const downloadQualitySettings = {
|
||||||
this.setQuality('FFMPEG_MP3_320');
|
this.setQuality('FFMPEG_MP3_320');
|
||||||
return 'FFMPEG_MP3_320';
|
return 'FFMPEG_MP3_320';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migrate legacy atmos value
|
||||||
|
if (stored === 'DOLBY_ATMOS') {
|
||||||
|
this.setQuality('HI_RES_LOSSLESS');
|
||||||
|
preferDolbyAtmosSettings.setEnabled(true);
|
||||||
|
return 'HI_RES_LOSSLESS';
|
||||||
|
}
|
||||||
|
|
||||||
return stored;
|
return stored;
|
||||||
} catch {
|
} catch {
|
||||||
return 'HI_RES_LOSSLESS';
|
return 'HI_RES_LOSSLESS';
|
||||||
|
|
@ -658,6 +666,21 @@ export const downloadQualitySettings = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const preferDolbyAtmosSettings = {
|
||||||
|
STORAGE_KEY: 'prefer-dolby-atmos',
|
||||||
|
isEnabled() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.STORAGE_KEY) || 'false';
|
||||||
|
return stored === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setEnabled(enabled) {
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const losslessContainerSettings = {
|
export const losslessContainerSettings = {
|
||||||
STORAGE_KEY: 'lossless-container',
|
STORAGE_KEY: 'lossless-container',
|
||||||
getContainer() {
|
getContainer() {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue