Skip to content

Commit

Permalink
Cleanup Request methods & migrate Tidal/Shazam API calls
Browse files Browse the repository at this point in the history
  • Loading branch information
Inrixia committed Jun 20, 2024
1 parent 1ca0923 commit d81db90
Show file tree
Hide file tree
Showing 32 changed files with 361 additions and 225 deletions.
66 changes: 66 additions & 0 deletions lib/fetch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type https from "https";
const { request } = <typeof https>require("https");

import { RequestOptions } from "https";
import type { Decipher } from "crypto";
import type { IncomingHttpHeaders, IncomingMessage } from "http";
import type { Readable } from "stream";

import { findModuleFunction } from "../findModuleFunction";

const getCredentials = findModuleFunction<() => Promise<{ token: string; clientId: string }>>("getCredentials", "function");
if (getCredentials === undefined) throw new Error("getCredentials method not found");

export const getHeaders = async (): Promise<Record<string, string>> => {
const { clientId, token } = await getCredentials();
return {
Authorization: `Bearer ${token}`,
"x-tidal-token": clientId,
};
};

export type OnProgress = (info: { total: number; downloaded: number; percent: number }) => void;
export interface FetchyOptions {
onProgress?: OnProgress;
bytesWanted?: number;
getDecipher?: () => Promise<Decipher>;
requestOptions?: RequestOptions;
}

export const rejectNotOk = (res: IncomingMessage) => {
const OK = res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300;
if (!OK) throw new Error(`Status code is ${res.statusCode}`);
return res;
};
export const toJson = <T>(res: IncomingMessage): Promise<T> => toBuffer(res).then((buffer) => JSON.parse(buffer.toString()));
export const toBuffer = (stream: Readable) =>
new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on("data", (chunk) => chunks.push(chunk));
stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on("error", reject);
});

type RequestOptionsWithBody = RequestOptions & { body?: string };
export const requestStream = (url: string, options: RequestOptionsWithBody = {}) =>
new Promise<IncomingMessage>((resolve, reject) => {
const body = options.body;
delete options.body;
options.headers ??= {};
options.headers["user-agent"] = navigator.userAgent;
const req = request(url, options, resolve);
req.on("error", reject);
if (body !== undefined) req.write(body);
req.end();
});

export const parseTotal = (headers: IncomingHttpHeaders) => {
if (headers["content-range"]) {
// Server supports byte range, parse total file size from header
const match = /\/(\d+)$/.exec(headers["content-range"]);
if (match) return parseInt(match[1], 10);
} else {
if (headers["content-length"] !== undefined) return parseInt(headers["content-length"], 10);
}
return -1;
};
49 changes: 49 additions & 0 deletions lib/fetch/requestDecodedStream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Readable } from "stream";
import { FetchyOptions, requestStream, rejectNotOk, parseTotal } from ".";

import type stream from "stream";
const { Transform, PassThrough } = <typeof stream>require("stream");

export const requestDecodedStream = async (url: string, options?: FetchyOptions): Promise<Readable> =>
new Promise(async (resolve, reject) => {
const { onProgress, bytesWanted, getDecipher } = options ?? {};
const reqOptions = { ...(options?.requestOptions ?? {}) };
if (bytesWanted !== undefined) {
reqOptions.headers ??= {};
reqOptions.headers.Range = `bytes=0-${bytesWanted}`;
}

const res = await requestStream(url, reqOptions).then(rejectNotOk);
res.on("error", reject);

let downloaded = 0;
const total = parseTotal(res.headers);
if (total !== -1) onProgress?.({ total, downloaded, percent: (downloaded / total) * 100 });

if (getDecipher !== undefined) {
const decipher = await getDecipher();
resolve(
res.pipe(
new Transform({
async transform(chunk, _, callback) {
try {
downloaded += chunk.length;
onProgress?.({ total, downloaded, percent: (downloaded / total) * 100 });
callback(null, decipher.update(chunk));
} catch (err) {
callback(<Error>err);
}
},
async flush(callback) {
try {
callback(null, decipher.final());
} catch (err) {
callback(<Error>err);
}
},
})
)
);
}
resolve(res);
});
46 changes: 46 additions & 0 deletions lib/fetch/requestSegmentsStream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { Readable } from "stream";
import { FetchyOptions, requestStream, rejectNotOk, parseTotal } from ".";

import type stream from "stream";
const { Transform, PassThrough } = <typeof stream>require("stream");

export const requestSegmentsStream = async (segments: string[], options: FetchyOptions = {}) =>
new Promise<Readable>(async (resolve, reject) => {
const combinedStream = new PassThrough();

let { onProgress, bytesWanted } = options ?? {};
let downloaded = 0;
let total = 0;
if (bytesWanted === undefined) {
const buffers = await Promise.all(
segments.map(async (url) => {
const res = await requestStream(url).then(rejectNotOk);
total += parseTotal(res.headers);
const chunks: Buffer[] = [];
res.on("data", (chunk) => {
chunks.push(chunk);
downloaded += chunk.length;
onProgress?.({ total, downloaded, percent: (downloaded / total) * 100 });
});
res.on("error", reject);
return new Promise<Buffer>((resolve) => res.on("end", () => resolve(Buffer.concat(chunks))));
})
);
combinedStream.write(Buffer.concat(buffers));
} else {
for (const url of segments) {
const res = await requestStream(url).then(rejectNotOk);
total += parseTotal(res.headers);
res.on("data", (chunk) => {
combinedStream.write(chunk);
downloaded += chunk.length;
onProgress?.({ total, downloaded, percent: (downloaded / total) * 100 });
});
res.on("error", reject);
await new Promise((resolve) => res.on("end", resolve));
if (downloaded >= bytesWanted) break;
}
}
combinedStream.end();
resolve(combinedStream);
});
142 changes: 0 additions & 142 deletions lib/fetchy.ts

This file was deleted.

34 changes: 34 additions & 0 deletions lib/tidalDevApi/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { rejectNotOk, requestStream, toJson } from "../fetch";

const CLIENT_ID = "tzecdDS3Bbx00rMP";
const CLIENT_SECRET = "zhRnKETi4FeXNGB72yAPJDssJ1U3BBGqmvYKcaw3kk8=";

const tokenStore = {
token: "",
expiresAt: -1,
};
type TokenInfo = {
scope: string;
token_type: string;
access_token: string;
expires_in: number;
};
export const getToken = async () => {
if (tokenStore.expiresAt > Date.now()) return tokenStore.token;
const { access_token, expires_in } = await requestStream("https://auth.tidal.com/v1/oauth2/token", {
method: "POST",
headers: {
Authorization: `Basic ${btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "client_credentials",
}).toString(),
})
.then(rejectNotOk)
.then(toJson<TokenInfo>);

tokenStore.token = access_token;
tokenStore.expiresAt = Date.now() + (expires_in - 60) * 1000;
return tokenStore.token;
};
13 changes: 13 additions & 0 deletions lib/tidalDevApi/isrc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ISRCResponse } from "./types/ISRC";
import { requestStream, rejectNotOk, toJson } from "../fetch";
import { getToken } from "./auth";

export const fetchIsrc = async (isrc: string, limit?: number) =>
requestStream(`https://openapi.tidal.com/tracks/byIsrc?isrc=${isrc}&countryCode=US&limit=${limit ?? 100}`, {
headers: {
Authorization: `Bearer ${await getToken()}`,
"Content-Type": "application/vnd.tidal.v1+json",
},
})
.then(rejectNotOk)
.then(toJson<ISRCResponse>);
File renamed without changes.
File renamed without changes.
File renamed without changes.
6 changes: 4 additions & 2 deletions lib/download.ts → lib/trackBytes/download.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { ExtendedPlayackInfo, getPlaybackInfo, ManifestMimeType, TidalManifest } from "./getPlaybackInfo";
import { makeDecipheriv } from "./decryptBuffer";
import { FetchyOptions, requestDecodedStream, requestSegmentsStream } from "./fetchy";
import { AudioQuality } from "./AudioQualityTypes";
import { FetchyOptions } from "../fetch";
import { requestDecodedStream } from "../fetch/requestDecodedStream";
import { requestSegmentsStream } from "../fetch/requestSegmentsStream";
import { AudioQuality } from "../AudioQualityTypes";
import { decryptKeyId } from "./decryptKeyId";
import type { Readable } from "stream";

Expand Down
6 changes: 3 additions & 3 deletions lib/getPlaybackInfo.ts → lib/trackBytes/getPlaybackInfo.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { getHeaders } from "./fetchy";
import { audioQualities, AudioQuality } from "./AudioQualityTypes";
import { getHeaders } from "../fetch";
import { audioQualities, AudioQuality } from "../AudioQualityTypes";
import { TrackItem } from "neptune-types/tidal";
import type { Manifest as DashManifest } from "dasha";
import type dasha from "dasha";
import { Mutex } from "./mutex";
import { Mutex } from "../mutex";
const { parse } = <typeof dasha>require("dasha");

export enum ManifestMimeType {
Expand Down
Loading

0 comments on commit d81db90

Please sign in to comment.