457 lines
16 KiB
TypeScript
457 lines
16 KiB
TypeScript
import { expect, test, suite, vi } from 'vitest';
|
|
import { apiSettings, preferDolbyAtmosSettings, losslessContainerSettings } from './storage.js';
|
|
import { MusicAPI } from './music-api.js';
|
|
import { LyricsManager } from './lyrics.js';
|
|
import type { LosslessAPI } from './api.js';
|
|
import { HiFiClient } from './HiFi.js';
|
|
import { FileRef } from '!/@dantheman827/taglib-ts/src/fileRef.js';
|
|
import { Mp4File } from '!/@dantheman827/taglib-ts/src/mp4/mp4File.js';
|
|
import { MpegFile } from '!/@dantheman827/taglib-ts/src/mpeg/mpegFile.js';
|
|
import { FlacFile } from '!/@dantheman827/taglib-ts/src/flac/flacFile.js';
|
|
import { Mp4Atom, Mp4Atoms } from '!/@dantheman827/taglib-ts/src/mp4/mp4Atoms.js';
|
|
import { ByteVector, StringType } from '!/@dantheman827/taglib-ts/src/byteVector.js';
|
|
import { Mp4Codec } from '!/@dantheman827/taglib-ts/src/mp4/mp4Properties.js';
|
|
import { OggFile } from '!/@dantheman827/taglib-ts/src/ogg/oggFile.js';
|
|
import { ffmpeg } from './ffmpeg.js';
|
|
|
|
vi.mock(import('./storage.js'), async (importOriginal) => {
|
|
const mod = await importOriginal();
|
|
|
|
return {
|
|
...mod,
|
|
preferDolbyAtmosSettings: {
|
|
...mod.preferDolbyAtmosSettings,
|
|
isEnabled: vi.fn(),
|
|
setEnabled: vi.fn(),
|
|
},
|
|
losslessContainerSettings: {
|
|
...mod.losslessContainerSettings,
|
|
getContainer: vi.fn(),
|
|
setContainer: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock(import('./ffmpeg.js'), async (importOriginal) => {
|
|
const mod = await importOriginal();
|
|
|
|
return {
|
|
...mod,
|
|
ffmpeg: vi.fn(mod.ffmpeg),
|
|
};
|
|
});
|
|
|
|
vi.mock(import('./doTimed.js'), async (importOriginal) => {
|
|
const mod = await importOriginal();
|
|
|
|
return {
|
|
...mod,
|
|
doTimed: (label: string, fn: () => any) => {
|
|
return fn() as any;
|
|
},
|
|
doTimedAsync<T, R = T extends Promise<T> ? Promise<T> : T>(
|
|
message: string,
|
|
callback: () => R,
|
|
throwError: boolean = false
|
|
): R {
|
|
return new Promise<R>(async (resolve, reject) => {
|
|
try {
|
|
const ret = await callback();
|
|
resolve(ret);
|
|
} catch (err) {
|
|
if (throwError) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
resolve(undefined);
|
|
}
|
|
}) as R;
|
|
},
|
|
} satisfies typeof import('./doTimed.js');
|
|
});
|
|
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
enum Detection {
|
|
DolbyAtmos,
|
|
FlacHD,
|
|
FlacLossless,
|
|
AlacHD,
|
|
AlacLossless,
|
|
Mp4Flac,
|
|
AacLow,
|
|
AacReallyLow,
|
|
AacHigh,
|
|
AAC_256,
|
|
MP3_320,
|
|
MP3_256,
|
|
MP3_128,
|
|
OGG_320,
|
|
OGG_256,
|
|
OGG_128,
|
|
}
|
|
|
|
suite('Track Downloads', async () => {
|
|
const SILENCE_TRACK = 46022548;
|
|
const TRACK_ATMOS = 463900720; // Taylor Swift - The Fate of Ophelia
|
|
const TRACK_NO_LOSSLESS = 31097959; // deadmau5 - while(1<2)
|
|
|
|
const { LosslessAPI } = await import('./api.js');
|
|
await MusicAPI.initialize(apiSettings);
|
|
await LyricsManager.initialize(apiSettings);
|
|
await HiFiClient.initialize();
|
|
|
|
const api: LosslessAPI = MusicAPI.instance.tidalAPI;
|
|
|
|
async function downloadTrack(trackId: number, quality: string) {
|
|
const track = await (await HiFiClient.instance.getInfo(trackId)).json();
|
|
return await api.downloadTrack(trackId.toString(), quality, undefined, {
|
|
track: track.data,
|
|
triggerDownload: false,
|
|
});
|
|
}
|
|
|
|
test.beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
test.each([
|
|
{
|
|
display_quality: 'Dolby Atmos',
|
|
quality: 'HI_RES_LOSSLESS',
|
|
container: 'flac',
|
|
preferDolbyAtmos: true,
|
|
trackId: TRACK_ATMOS,
|
|
detection: Detection.DolbyAtmos,
|
|
ffmpegCalls: 0,
|
|
},
|
|
{
|
|
display_quality: 'HD Lossless (FLAC)',
|
|
quality: 'HI_RES_LOSSLESS',
|
|
container: 'flac',
|
|
preferDolbyAtmos: false,
|
|
trackId: SILENCE_TRACK,
|
|
detection: Detection.FlacHD,
|
|
ffmpegCalls: 1,
|
|
},
|
|
{
|
|
display_quality: 'Lossless (FLAC)',
|
|
quality: 'LOSSLESS',
|
|
container: 'flac',
|
|
preferDolbyAtmos: false,
|
|
trackId: SILENCE_TRACK,
|
|
detection: Detection.FlacLossless,
|
|
ffmpegCalls: 0,
|
|
},
|
|
{
|
|
display_quality: 'HD Lossless (ALAC)',
|
|
quality: 'HI_RES_LOSSLESS',
|
|
container: 'alac',
|
|
preferDolbyAtmos: false,
|
|
trackId: SILENCE_TRACK,
|
|
detection: Detection.AlacHD,
|
|
ffmpegCalls: 1,
|
|
},
|
|
{
|
|
display_quality: 'Lossless (ALAC)',
|
|
quality: 'LOSSLESS',
|
|
container: 'alac',
|
|
preferDolbyAtmos: false,
|
|
trackId: SILENCE_TRACK,
|
|
detection: Detection.AlacLossless,
|
|
ffmpegCalls: 1,
|
|
},
|
|
{
|
|
display_quality: 'HD Lossless (Unchanged)',
|
|
quality: 'HI_RES_LOSSLESS',
|
|
container: 'nochange',
|
|
preferDolbyAtmos: false,
|
|
trackId: SILENCE_TRACK,
|
|
detection: Detection.Mp4Flac,
|
|
ffmpegCalls: 0,
|
|
},
|
|
{
|
|
display_quality: 'Lossless (Unchanged)',
|
|
quality: 'LOSSLESS',
|
|
container: 'nochange',
|
|
preferDolbyAtmos: false,
|
|
trackId: SILENCE_TRACK,
|
|
detection: Detection.FlacLossless,
|
|
ffmpegCalls: 0,
|
|
},
|
|
{
|
|
display_quality: 'Lossless, but not really',
|
|
quality: 'HI_RES_LOSSLESS',
|
|
container: 'flac',
|
|
preferDolbyAtmos: false,
|
|
trackId: TRACK_NO_LOSSLESS,
|
|
detection: Detection.AacReallyLow,
|
|
ffmpegCalls: 0,
|
|
},
|
|
{
|
|
display_quality: 'High',
|
|
quality: 'HIGH',
|
|
container: 'flac',
|
|
preferDolbyAtmos: false,
|
|
trackId: SILENCE_TRACK,
|
|
detection: Detection.AacHigh,
|
|
ffmpegCalls: 0,
|
|
},
|
|
{
|
|
display_quality: 'Low',
|
|
quality: 'LOW',
|
|
container: 'flac',
|
|
preferDolbyAtmos: false,
|
|
trackId: SILENCE_TRACK,
|
|
detection: Detection.AacLow,
|
|
ffmpegCalls: 0,
|
|
},
|
|
|
|
{
|
|
display_quality: 'AAC 256',
|
|
quality: 'FFMPEG_AAC_256',
|
|
container: 'flac',
|
|
preferDolbyAtmos: false,
|
|
trackId: TRACK_ATMOS,
|
|
detection: Detection.AAC_256,
|
|
ffmpegCalls: 1,
|
|
},
|
|
|
|
{
|
|
display_quality: 'MP3 320',
|
|
quality: 'FFMPEG_MP3_320',
|
|
container: 'flac',
|
|
preferDolbyAtmos: false,
|
|
trackId: SILENCE_TRACK,
|
|
detection: Detection.MP3_320,
|
|
ffmpegCalls: 1,
|
|
},
|
|
{
|
|
display_quality: 'MP3 256',
|
|
quality: 'FFMPEG_MP3_256',
|
|
container: 'flac',
|
|
preferDolbyAtmos: false,
|
|
trackId: SILENCE_TRACK,
|
|
detection: Detection.MP3_256,
|
|
ffmpegCalls: 1,
|
|
},
|
|
{
|
|
display_quality: 'MP3 128',
|
|
quality: 'FFMPEG_MP3_128',
|
|
container: 'flac',
|
|
preferDolbyAtmos: false,
|
|
trackId: SILENCE_TRACK,
|
|
detection: Detection.MP3_128,
|
|
ffmpegCalls: 1,
|
|
},
|
|
|
|
{
|
|
display_quality: 'OGG 320',
|
|
quality: 'FFMPEG_OGG_320',
|
|
container: 'flac',
|
|
preferDolbyAtmos: false,
|
|
trackId: SILENCE_TRACK,
|
|
detection: Detection.OGG_320,
|
|
ffmpegCalls: 1,
|
|
},
|
|
{
|
|
display_quality: 'OGG 256',
|
|
quality: 'FFMPEG_OGG_256',
|
|
container: 'flac',
|
|
preferDolbyAtmos: false,
|
|
trackId: SILENCE_TRACK,
|
|
detection: Detection.OGG_256,
|
|
ffmpegCalls: 1,
|
|
},
|
|
{
|
|
display_quality: 'OGG 128',
|
|
quality: 'FFMPEG_OGG_128',
|
|
container: 'flac',
|
|
preferDolbyAtmos: false,
|
|
trackId: SILENCE_TRACK,
|
|
detection: Detection.OGG_128,
|
|
ffmpegCalls: 1,
|
|
},
|
|
])('$display_quality', async ({ quality, container, preferDolbyAtmos, trackId, detection, ffmpegCalls }) => {
|
|
vi.mocked(preferDolbyAtmosSettings.isEnabled).mockReturnValue(preferDolbyAtmos);
|
|
vi.mocked(losslessContainerSettings.getContainer).mockReturnValue(container);
|
|
|
|
const blob = await downloadTrack(trackId, quality);
|
|
expect(ffmpeg).toHaveBeenCalledTimes(ffmpegCalls);
|
|
const file = await FileRef.fromBlob(blob);
|
|
const stream = file.file().stream();
|
|
|
|
expect(file.isValid).toBe(true);
|
|
|
|
let trak: Mp4Atom | null = null;
|
|
let stsd: Mp4Atom | null = null;
|
|
let stsdData: ByteVector | null = null;
|
|
|
|
const streamPosition = await stream.tell();
|
|
|
|
if (file.file() instanceof Mp4File) {
|
|
const atoms = await Mp4Atoms.create(stream);
|
|
const moov = atoms.find('moov');
|
|
expect(moov).not.toBeNull();
|
|
|
|
let trak: Mp4Atom | null = null;
|
|
let data: ByteVector;
|
|
|
|
const trakList = moov.findAll('trak');
|
|
for (const track of trakList) {
|
|
const hdlr = track.find('mdia', 'hdlr');
|
|
if (!hdlr) continue;
|
|
trak = track;
|
|
await stream.seek(hdlr.offset);
|
|
data = await stream.readBlock(hdlr.length);
|
|
if (data.containsAt(ByteVector.fromString('soun', StringType.Latin1), 16)) {
|
|
break;
|
|
}
|
|
trak = null;
|
|
}
|
|
expect(trak).toBeInstanceOf(Mp4Atom);
|
|
stsd = trak!.find('mdia', 'minf', 'stbl', 'stsd');
|
|
expect(stsd).toBeInstanceOf(Mp4Atom);
|
|
await stream.seek(stsd.offset);
|
|
stsdData = await stream.readBlock(stsd.length);
|
|
}
|
|
|
|
stream.seek(streamPosition);
|
|
|
|
switch (detection) {
|
|
case Detection.DolbyAtmos: {
|
|
expect(file.file()).toBeInstanceOf(Mp4File);
|
|
const codec = stsdData.toString().substring(20, 24);
|
|
expect(codec).toBe('ec-3');
|
|
break;
|
|
}
|
|
case Detection.FlacHD: {
|
|
expect(file.file()).toBeInstanceOf(FlacFile);
|
|
const flac = file.file() as FlacFile;
|
|
expect(flac.audioProperties().bitsPerSample).toBe(24);
|
|
expect(flac.audioProperties().sampleRate).toBe(176400);
|
|
break;
|
|
}
|
|
case Detection.FlacLossless: {
|
|
expect(file.file()).toBeInstanceOf(FlacFile);
|
|
const flac = file.file() as FlacFile;
|
|
expect(flac.audioProperties().bitsPerSample).toBe(16);
|
|
expect(flac.audioProperties().sampleRate).toBe(44100);
|
|
break;
|
|
}
|
|
case Detection.Mp4Flac: {
|
|
expect(file.file()).toBeInstanceOf(Mp4File);
|
|
const codec = stsdData.toString().substring(20, 24);
|
|
expect(codec).toBe('fLaC');
|
|
break;
|
|
}
|
|
case Detection.AlacHD: {
|
|
expect(file.file()).toBeInstanceOf(Mp4File);
|
|
const mp4 = file.file() as Mp4File;
|
|
expect(mp4.audioProperties().codec).toBe(Mp4Codec.ALAC);
|
|
expect(mp4.audioProperties().bitsPerSample).toBe(24);
|
|
expect(mp4.audioProperties().sampleRate).toBe(176400);
|
|
break;
|
|
}
|
|
case Detection.AlacLossless: {
|
|
expect(file.file()).toBeInstanceOf(Mp4File);
|
|
const mp4 = file.file() as Mp4File;
|
|
expect(mp4.audioProperties().codec).toBe(Mp4Codec.ALAC);
|
|
expect(mp4.audioProperties().bitsPerSample).toBe(16);
|
|
expect(mp4.audioProperties().sampleRate).toBe(44100);
|
|
break;
|
|
}
|
|
case Detection.AacLow: {
|
|
expect(file.file()).toBeInstanceOf(Mp4File);
|
|
const mp4 = file.file() as Mp4File;
|
|
expect(mp4.audioProperties().codec).toBe(Mp4Codec.AAC);
|
|
expect(mp4.audioProperties().bitsPerSample).toBe(16);
|
|
expect(mp4.audioProperties().sampleRate).toBe(44100);
|
|
expect(mp4.audioProperties().bitrate).toBe(97);
|
|
break;
|
|
}
|
|
case Detection.AacReallyLow: {
|
|
expect(file.file()).toBeInstanceOf(Mp4File);
|
|
const mp4 = file.file() as Mp4File;
|
|
expect(mp4.audioProperties().codec).toBe(Mp4Codec.AAC);
|
|
expect(mp4.audioProperties().bitsPerSample).toBe(16);
|
|
expect(mp4.audioProperties().sampleRate).toBe(22050);
|
|
expect(mp4.audioProperties().bitrate).toBe(97);
|
|
break;
|
|
}
|
|
case Detection.AacHigh: {
|
|
expect(file.file()).toBeInstanceOf(Mp4File);
|
|
const mp4 = file.file() as Mp4File;
|
|
expect(mp4.audioProperties().codec).toBe(Mp4Codec.AAC);
|
|
expect(mp4.audioProperties().bitsPerSample).toBe(16);
|
|
expect(mp4.audioProperties().sampleRate).toBe(44100);
|
|
expect(mp4.audioProperties().bitrate).toBe(322);
|
|
break;
|
|
}
|
|
|
|
case Detection.AAC_256: {
|
|
expect(file.file()).toBeInstanceOf(Mp4File);
|
|
const mp4 = file.file() as Mp4File;
|
|
expect(mp4.audioProperties().codec).toBe(Mp4Codec.AAC);
|
|
expect(mp4.audioProperties().bitsPerSample).toBe(16);
|
|
expect(mp4.audioProperties().sampleRate).toBe(44100);
|
|
expect(mp4.audioProperties().bitrate).toBe(263);
|
|
break;
|
|
}
|
|
|
|
case Detection.MP3_320: {
|
|
expect(file.file()).toBeInstanceOf(MpegFile);
|
|
const mp3 = file.file() as MpegFile;
|
|
expect(mp3.audioProperties().sampleRate).toBe(44100);
|
|
expect(mp3.audioProperties().bitrate).toBe(322);
|
|
break;
|
|
}
|
|
|
|
case Detection.MP3_256: {
|
|
expect(file.file()).toBeInstanceOf(MpegFile);
|
|
const mp3 = file.file() as MpegFile;
|
|
expect(mp3.audioProperties().sampleRate).toBe(44100);
|
|
expect(mp3.audioProperties().bitrate).toBe(258);
|
|
break;
|
|
}
|
|
|
|
case Detection.MP3_128: {
|
|
expect(file.file()).toBeInstanceOf(MpegFile);
|
|
const mp3 = file.file() as MpegFile;
|
|
expect(mp3.audioProperties().sampleRate).toBe(44100);
|
|
expect(mp3.audioProperties().bitrate).toBe(129);
|
|
break;
|
|
}
|
|
|
|
case Detection.OGG_320: {
|
|
expect(file.file()).toBeInstanceOf(OggFile);
|
|
const ogg = file.file() as OggFile;
|
|
expect(ogg.audioProperties().sampleRate).toBe(44100);
|
|
//expect(ogg.audioProperties().bitrate).toBe(320);
|
|
break;
|
|
}
|
|
|
|
case Detection.OGG_256: {
|
|
expect(file.file()).toBeInstanceOf(OggFile);
|
|
const ogg = file.file() as OggFile;
|
|
expect(ogg.audioProperties().sampleRate).toBe(44100);
|
|
//expect(ogg.audioProperties().bitrate).toBe(256);
|
|
break;
|
|
}
|
|
|
|
case Detection.OGG_128: {
|
|
expect(file.file()).toBeInstanceOf(OggFile);
|
|
const ogg = file.file() as OggFile;
|
|
expect(ogg.audioProperties().sampleRate).toBe(44100);
|
|
//expect(ogg.audioProperties().bitrate).toBe(128);
|
|
break;
|
|
}
|
|
|
|
default:
|
|
throw new Error('Unknown detection type');
|
|
}
|
|
});
|
|
});
|