Skip to content

Commit

Permalink
Support for Low quality track downloading & info
Browse files Browse the repository at this point in the history
  • Loading branch information
Inrixia committed Apr 22, 2024
1 parent 159d67b commit cadaf5a
Show file tree
Hide file tree
Showing 13 changed files with 342 additions and 148 deletions.
22 changes: 11 additions & 11 deletions lib/AudioQuality.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
export enum AudioQuality {
export enum AudioQualityEnum {
HiRes = "HI_RES_LOSSLESS",
MQA = "HI_RES",
High = "LOSSLESS",
Low = "HIGH",
Lowest = "LOW",
}
export enum QualityTag {
export enum QualityTagEnum {
HiRes = "HIRES_LOSSLESS",
MQA = "MQA",
High = "LOSSLESS",
Expand All @@ -14,20 +14,20 @@ export enum QualityTag {
}

export type PlaybackContext = {
actualAudioQuality: AudioQuality;
actualAudioQuality: AudioQualityEnum;
actualProductId: number;
};

export const QualityMeta = {
[QualityTag.MQA]: { textContent: "MQA", color: "#F9BA7A" },
[QualityTag.HiRes]: { textContent: "HiRes", color: "#ffd432" },
[QualityTag.DolbyAtmos]: { textContent: "Atmos", color: "#0052a3" },
[QualityTag.Sony630]: undefined,
[QualityTag.High]: undefined,
[QualityTagEnum.MQA]: { textContent: "MQA", color: "#F9BA7A" },
[QualityTagEnum.HiRes]: { textContent: "HiRes", color: "#ffd432" },
[QualityTagEnum.DolbyAtmos]: { textContent: "Atmos", color: "#0052a3" },
[QualityTagEnum.Sony630]: undefined,
[QualityTagEnum.High]: undefined,
} as const;

export const audioQualities = Object.values(AudioQuality);
export const audioQualities = Object.values(AudioQualityEnum);
// Dont show MQA as a option as if HiRes is avalible itl always be served even if MQA is requested.
export const validQualitiesSettings: AudioQuality[] = [AudioQuality.HiRes, AudioQuality.High];
export const validQualitiesSettings: AudioQualityEnum[] = [AudioQualityEnum.HiRes, AudioQualityEnum.High];

export const AudioQualityInverse = Object.fromEntries(Object.entries(AudioQuality).map(([key, value]) => [value, key]));
export const AudioQualityInverse = Object.fromEntries(Object.entries(AudioQualityEnum).map(([key, value]) => [value, key]));
14 changes: 0 additions & 14 deletions lib/decryptBuffer.js

This file was deleted.

16 changes: 16 additions & 0 deletions lib/decryptBuffer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { DecryptedKey } from "./decryptKeyId";
import type crypto from "crypto";
const { createDecipheriv } = <typeof crypto>require("crypto");

export const decryptBuffer = async (encryptedBuffer: Buffer, { key, nonce }: DecryptedKey) => {
// Extend nonce to 16 bytes (nonce + counter)
const iv = Buffer.concat([nonce, Buffer.alloc(8, 0)]);

// Initialize counter and file decryptor
const decipher = createDecipheriv("aes-128-ctr", key, iv);

// Decrypt the data
const decryptedData = Buffer.concat([decipher.update(encryptedBuffer), decipher.final()]);

return decryptedData;
};
7 changes: 6 additions & 1 deletion lib/decryptKeyId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ const { createDecipheriv } = <typeof crypto>require("crypto");
// Do not change this
const mastKey = "UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754=";

export const decryptKeyId = async (keyId: string) => {
export type DecryptedKey = {
key: Buffer;
nonce: Buffer;
};

export const decryptKeyId = async (keyId: string): Promise<DecryptedKey> => {
// Decode the base64 strings to buffers
const mastKeyBuffer = Buffer.from(mastKey, "base64");
const keyIdBuffer = Buffer.from(keyId, "base64");
Expand Down
72 changes: 50 additions & 22 deletions lib/download.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,60 @@
import { getStreamInfo } from "./getStreamInfo";
import { ExtendedPlayackInfo, getPlaybackInfo, ManifestMimeType } from "./getStreamInfo";
import { decryptBuffer } from "./decryptBuffer";
import { OnProgress, fetchy } from "./fetchy";
import { FetchyOptions, OnProgress, fetchy } from "./fetchy";
import { saveFile } from "./saveFile";
import { AudioQualityInverse, AudioQuality } from "./AudioQuality";
import { AudioQualityEnum } from "./AudioQuality";
import { decryptKeyId } from "./decryptKeyId";
import { TrackItem } from "neptune-types/tidal";

export const downloadSong = async (songId: number, fileName: string, quality: AudioQuality, onProgress: OnProgress) => {
const streamInfo = await getStreamInfo(songId, quality);

const { key, nonce } = streamInfo.cryptKey;
const url = streamInfo.manifest.urls[0];
export type TrackOptions = {
songId: number;
desiredQuality: AudioQualityEnum;
};

const encryptedBuffer = await fetchy(url, onProgress);
export const fileNameFromInfo = (track: TrackItem, { manifest, manifestMimeType }: ExtendedPlayackInfo): string => {
const artistName = track.artists?.[0].name;
const base = `${track.title} by ${artistName ?? "Unknown"}`;
switch (manifestMimeType) {
case ManifestMimeType.Tidal: {
const codec = manifest.codecs !== "flac" ? `.${manifest.codecs}` : "";
return `${base}${codec}.flac`;
}
case ManifestMimeType.Dash: {
const trackManifest = manifest.tracks.audios[0];
return `${base}.${trackManifest.codec}.mp4`;
}
}
};

// Read the encrypted data from the Response object
const decodedBuffer = await decryptBuffer(encryptedBuffer, key, nonce);
export const saveTrack = async (track: TrackItem, trackOptions: TrackOptions, options?: DownloadTrackOptions) => {
// Download the bytes
const trackInfo = await downloadTrack(trackOptions, options);

// Prompt the user to save the file
saveFile(new Blob([decodedBuffer], { type: "application/octet-stream" }), `${fileName} [${AudioQualityInverse[streamInfo.audioQuality]}].flac`);
saveFile(new Blob([trackInfo.buffer], { type: "application/octet-stream" }), fileNameFromInfo(track, trackInfo));
};

export const downloadBytes = async (songId: number, quality: AudioQuality, byteRangeStart = 0, byteRangeEnd: number, onProgress: OnProgress) => {
const streamInfo = await getStreamInfo(songId, quality);

const { key, nonce } = streamInfo.cryptKey;
const url = streamInfo.manifest.urls[0];

const encryptedBuffer = await fetchy(url, onProgress, byteRangeStart, byteRangeEnd);

// Read the encrypted data from the Response object
return decryptBuffer(encryptedBuffer, key, nonce);
export type ExtendedPlaybackInfoWithBytes = ExtendedPlayackInfo & { buffer: Buffer };

export interface DownloadTrackOptions extends FetchyOptions {
playbackInfo?: ExtendedPlayackInfo;
}

export const downloadTrack = async ({ songId, desiredQuality }: TrackOptions, options?: DownloadTrackOptions): Promise<ExtendedPlaybackInfoWithBytes> => {
const { playbackInfo, manifest, manifestMimeType } = options?.playbackInfo ?? (await getPlaybackInfo(songId, desiredQuality));

switch (manifestMimeType) {
case ManifestMimeType.Tidal: {
const encryptedBuffer = await fetchy(manifest.urls[0], options);
const decryptedKey = await decryptKeyId(manifest.keyId);
const buffer = await decryptBuffer(encryptedBuffer, decryptedKey);
return { playbackInfo, manifest, manifestMimeType, buffer };
}
case ManifestMimeType.Dash: {
if (options?.headers?.["range"] !== undefined) throw new Error("Range header not supported for dash streams");
const trackManifest = manifest.tracks.audios[0];
const buffer = Buffer.concat(await Promise.all(trackManifest.segments.map(({ url }) => fetchy(url.replaceAll("amp;", ""), options))));
return { playbackInfo, manifest, manifestMimeType, buffer };
}
}
};
71 changes: 33 additions & 38 deletions lib/fetchy.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type https from "https";
import type { RequestOptions } from "https";

const { request } = <typeof https>require("https");

import { modules } from "@neptune";

const findModuleFunction = (functionName: string) => {
for (const module of modules) {
if (typeof module?.exports !== "object") continue;
Expand All @@ -22,48 +25,40 @@ export const getHeaders = async (): Promise<Record<string, string>> => {
};

export type OnProgress = (info: { total: number; downloaded: number; percent: number }) => void;
export interface FetchyOptions extends RequestOptions {
onProgress?: OnProgress;
}

export const fetchy = async (url: string, onProgress: OnProgress, byteRangeStart = 0, byteRangeEnd?: number) => {
const headers = await getHeaders();
if (typeof byteRangeStart !== "number") throw new Error("byteRangeStart must be a number");
if (byteRangeEnd !== undefined) {
if (typeof byteRangeEnd !== "number") throw new Error("byteRangeEnd must be a number");
headers["Range"] = `bytes=${byteRangeStart}-${byteRangeEnd}`;
}
return new Promise((resolve, reject) => {
const req = request(
url,
{
headers,
},
(res) => {
let total = -1;
export const fetchy = async (url: string, options?: FetchyOptions): Promise<Buffer> =>
new Promise((resolve, reject) => {
const { onProgress } = options ?? {};
const req = request(url, options ?? {}, (res) => {
const OK = res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300;
if (!OK) reject(new Error(`Status code is ${res.statusCode}`));
let total = -1;

if (res.headers["content-range"]) {
// Server supports byte range, parse total file size from header
const match = /\/(\d+)$/.exec(res.headers["content-range"]);
if (match) total = parseInt(match[1], 10);
} else {
if (res.headers["content-length"] !== undefined) total = parseInt(res.headers["content-length"], 10);
}
if (res.headers["content-range"]) {
// Server supports byte range, parse total file size from header
const match = /\/(\d+)$/.exec(res.headers["content-range"]);
if (match) total = parseInt(match[1], 10);
} else {
if (res.headers["content-length"] !== undefined) total = parseInt(res.headers["content-length"], 10);
}

let downloaded = 0;
const chunks: Buffer[] = [];
let downloaded = 0;
const chunks: Buffer[] = [];

res.on("data", (chunk: Buffer) => {
chunks.push(chunk);
downloaded += chunk.length;
if (onProgress !== undefined) onProgress({ total, downloaded, percent: (downloaded / total) * 100 });
});
res.on("end", () => {
// Chunks is an array of Buffer objects.
const chunkyBuffer = Buffer.concat(chunks);
if (onProgress !== undefined) onProgress({ total, downloaded: total, percent: 100 });
resolve(chunkyBuffer);
});
}
);
res.on("data", (chunk: Buffer) => {
chunks.push(chunk);
downloaded += chunk.length;
if (onProgress !== undefined) onProgress({ total, downloaded, percent: (downloaded / total) * 100 });
});
res.on("end", () => {
if (onProgress !== undefined) onProgress({ total, downloaded: total, percent: 100 });
// Chunks is an array of Buffer objects.
resolve(Buffer.concat(chunks));
});
});
req.on("error", reject);
req.end();
});
};
61 changes: 52 additions & 9 deletions lib/getStreamInfo.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,51 @@
import { decryptKeyId } from "./decryptKeyId";
import { getHeaders } from "./fetchy";
import { audioQualities, AudioQuality } from "./AudioQuality";
import { audioQualities, AudioQualityEnum } from "./AudioQuality";
import { saveFile } from "./saveFile";
import { TrackItem } from "neptune-types/tidal";
import type { Manifest as DashManifest } from "dasha";
import type dasha from "dasha";
const { parse } = <typeof dasha>require("dasha");

export const getStreamInfo = async (trackId: number, audioQuality: AudioQuality) => {
export enum ManifestMimeType {
Tidal = "application/vnd.tidal.bts",
Dash = "application/dash+xml",
}

export type PlaybackInfo = {
trackId: number;
assetPresentation: string;
audioMode: NonNullable<TrackItem["audioModes"]>;
audioQuality: NonNullable<TrackItem["audioQuality"]>;
manifestMimeType: ManifestMimeType;
manifestHash: string;
manifest: string;
albumReplayGain: number;
albumPeakAmplitude: number;
trackReplayGain: number;
trackPeakAmplitude: number;
};

export type TidalManifest = {
mimeType: string;
codecs: string;
encryptionType: string;
keyId: string;
urls: string[];
};

export type ExtendedPlayackInfo =
| { playbackInfo: PlaybackInfo; manifestMimeType: ManifestMimeType.Dash; manifest: DashManifest }
| { playbackInfo: PlaybackInfo; manifestMimeType: ManifestMimeType.Tidal; manifest: TidalManifest };

export const getPlaybackInfo = async (trackId: number, audioQuality: AudioQualityEnum): Promise<ExtendedPlayackInfo> => {
if (!audioQualities.includes(audioQuality)) throw new Error(`Cannot get Stream Info! Invalid audio quality: ${audioQuality}, should be one of ${audioQualities.join(", ")}`);
if (trackId === undefined) throw new Error("Cannot get Stream Info! trackId is missing");

try {
const url = `https://desktop.tidal.com/v1/tracks/${trackId}/playbackinfo?audioquality=${audioQuality}&playbackmode=STREAM&assetpresentation=FULL`;

const playbackInfo = await fetch(url, {
const playbackInfo: PlaybackInfo = await fetch(url, {
headers: await getHeaders(),
}).then((r) => {
if (r.status === 401) {
Expand All @@ -19,12 +55,19 @@ export const getStreamInfo = async (trackId: number, audioQuality: AudioQuality)
return r.json();
});

const manifest = JSON.parse(atob(playbackInfo.manifest));
if (manifest.encryptionType !== "OLD_AES") throw new Error(`Unexpected manifest encryption type ${manifest.encryptionType}`);

playbackInfo.manifest = manifest;
playbackInfo.cryptKey = await decryptKeyId(manifest.keyId);
return playbackInfo;
switch (playbackInfo.manifestMimeType) {
case ManifestMimeType.Tidal: {
const manifest: TidalManifest = JSON.parse(atob(playbackInfo.manifest));
if (manifest.encryptionType !== "OLD_AES") throw new Error(`Unexpected manifest encryption type ${manifest.encryptionType}`);
return { playbackInfo, manifestMimeType: playbackInfo.manifestMimeType, manifest };
}
case ManifestMimeType.Dash: {
return { playbackInfo, manifestMimeType: playbackInfo.manifestMimeType, manifest: await parse(atob(playbackInfo.manifest), "https://sp-ad-cf.audio.tidal.com") };
}
default: {
throw new Error(`Unsupported Stream Info manifest mime type: ${playbackInfo.manifestMimeType}`);
}
}
} catch (e) {
throw new Error(`Failed to decode Stream Info! ${(<Error>e)?.message}`);
}
Expand Down
Loading

0 comments on commit cadaf5a

Please sign in to comment.