Skip to content

Commit

Permalink
RealMAX - WIP | TidalTags & fetch fixes
Browse files Browse the repository at this point in the history
  • Loading branch information
Inrixia committed Jun 20, 2024
1 parent d81db90 commit b961aa8
Show file tree
Hide file tree
Showing 20 changed files with 200 additions and 145 deletions.
22 changes: 22 additions & 0 deletions lib/Semaphore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export class Semaphore {
private avalibleSlots: number;
private readonly queued: (() => void)[] = [];

constructor(slots: number) {
this.avalibleSlots = slots;
}

public async obtain() {
// If there is an available request slot, proceed immediately
if (this.avalibleSlots > 0) return this.avalibleSlots--;

// Otherwise, wait for a request slot to become available
return new Promise((r) => this.queued.push(() => r(this.avalibleSlots--)));
}

public release(): void {
this.avalibleSlots++;
// If there are queued requests, resolve the first one in the queue
this.queued.shift()?.();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { TrackItem, MediaItem } from "neptune-types/tidal";

export class TrackItemCache {
private static readonly _cache: Map<string, TrackItem> = new Map<string, TrackItem>();
public static get(trackId: string) {
public static get(trackId: number | string | undefined) {
if (trackId === undefined) return undefined;
trackId = trackId.toString();
let mediaItem = TrackItemCache._cache.get(trackId);
if (mediaItem !== undefined) return mediaItem;
const mediaItems: Record<number, MediaItem> = store.getState().content.mediaItems;
Expand Down
11 changes: 11 additions & 0 deletions lib/debounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const debounce = <T extends (...args: any[]) => any>(func: T, wait: number): ((...args: Parameters<T>) => void) => {
let timeout: NodeJS.Timeout | null;
return async function (this: ThisParameterType<T>, ...args: Parameters<T>) {
const context = this;
if (timeout) clearTimeout(timeout);
timeout = setTimeout(async () => {
timeout = null;
func.apply(context, args);
}, wait);
};
};
8 changes: 6 additions & 2 deletions lib/fetch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,12 @@ export const requestStream = (url: string, options: RequestOptionsWithBody = {})
const body = options.body;
delete options.body;
options.headers ??= {};
options.headers["user-agent"] = navigator.userAgent;
const req = request(url, options, resolve);
options.headers["user-agent"] ??= navigator.userAgent;
const req = request(url, options, (res) => {
const statusMsg = res.statusMessage !== "" ? ` - ${res.statusMessage}` : "";
console.debug(`[${res.statusCode}${statusMsg}] (${req.method})`, url, res);
resolve(res);
});
req.on("error", reject);
if (body !== undefined) req.write(body);
req.end();
Expand Down
22 changes: 22 additions & 0 deletions lib/interceptActions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { intercept } from "@neptune";
import { ActionType, UninterceptFunction } from "neptune-types/api/intercept";
import { ActionTypes } from "neptune-types/tidal";

function convertToUpperCaseWithUnderscores(str: string) {
return str
.replace(/([a-z0-9])([A-Z])/g, "$1_$2") // Convert camelCase to snake_case
.toUpperCase(); // Convert to uppercase
}
const neptuneActions = window.neptune.actions;
export type ActionHandler = <AT extends ActionType>(interceptPath: AT, payload: ActionTypes[AT]) => void;
export const interceptActions = (actionPath: RegExp, handler: ActionHandler) => {
const unloadables: UninterceptFunction[] = [];
for (const item in neptuneActions) {
for (const action in window.neptune.actions[<keyof typeof neptuneActions>item]) {
const interceptPath = `${item}/${convertToUpperCaseWithUnderscores(action)}`;
if (!actionPath.test(interceptPath)) continue;
unloadables.push(intercept(<ActionType>interceptPath, (payload) => handler(payload[1], payload[0])));
}
}
return () => unloadables.forEach((u) => u());
};
26 changes: 0 additions & 26 deletions lib/mutex.ts

This file was deleted.

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

const CLIENT_ID = "tzecdDS3Bbx00rMP";
const CLIENT_SECRET = "zhRnKETi4FeXNGB72yAPJDssJ1U3BBGqmvYKcaw3kk8=";
Expand All @@ -13,22 +14,28 @@ type TokenInfo = {
access_token: string;
expires_in: number;
};
const authSema = new Semaphore(1);
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>);
await authSema.obtain();
try {
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;
tokenStore.token = access_token;
tokenStore.expiresAt = Date.now() + (expires_in - 60) * 1000;
return tokenStore.token;
} finally {
await authSema.release();
}
};
23 changes: 20 additions & 3 deletions lib/tidalDevApi/isrc.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
import { ISRCResponse } from "./types/ISRC";
import { ISRCResponse, TrackData } 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}`, {
type ISRCOptions = {
offset: number;
limit: number;
};
export const fetchIsrc = async (isrc: string, options?: ISRCOptions) => {
const { limit, offset } = options ?? { limit: 100, offset: 0 };
return requestStream(`https://openapi.tidal.com/tracks/byIsrc?isrc=${isrc}&countryCode=US&limit=${limit}&offset=${offset}`, {
headers: {
Authorization: `Bearer ${await getToken()}`,
"Content-Type": "application/vnd.tidal.v1+json",
},
})
.then(rejectNotOk)
.then(toJson<ISRCResponse>);
};

export async function* fetchIsrcIterable(isrc: string): AsyncIterable<TrackData> {
let offset = 0;
const limit = 100;
while (true) {
const response = await fetchIsrc(isrc, { limit, offset });
if (response?.data !== undefined) yield* response.data;
if (response.data.length < limit) break;
offset += limit;
}
}
70 changes: 9 additions & 61 deletions lib/tidalDevApi/types/ISRC.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,17 @@
type ImageResource = {
url: string;
width: number;
height: number;
};

type SimpleArtist = {
id: string;
name: string;
picture: ImageResource[];
main: boolean;
};

type SimpleAlbum = {
id: string;
title: string;
imageCover: ImageResource[];
videoCover: ImageResource[];
};

type ProviderInfo = {
providerId?: string;
providerName?: string;
};
import type { TrackItem } from "neptune-types/tidal";

type MediaMeta = {
tags: string[];
};

type Track = {
id: string;
version: string;
duration: number;
album: SimpleAlbum;
title: string;
copyright: string;
artists: SimpleArtist[];
popularity?: number;
isrc: string;
trackNumber: number;
volumeNumber: number;
tidalUrl: string;
providerInfo?: ProviderInfo;
artifactType: string;
mediaMetadata: MediaMeta;
};

type TrackProperties = {
content: string[];
};

type MultiStatusResponseDataTrack = {
resource: Track;
properties: TrackProperties;
export type TrackData = {
resource: TrackItem;
id: string;
status: number;
message: string;
};

type MultiStatusResponseMetadata = {
requested: number;
success: number;
failure: number;
};

export type ISRCResponse = {
data: MultiStatusResponseDataTrack[];
metadata: MultiStatusResponseMetadata;
data: TrackData[];
metadata: {
requested: number;
success: number;
failure: number;
};
};
8 changes: 4 additions & 4 deletions lib/trackBytes/getPlaybackInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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 { Semaphore } from "../Semaphore";
const { parse } = <typeof dasha>require("dasha");

export enum ManifestMimeType {
Expand Down Expand Up @@ -37,12 +37,12 @@ export type ExtendedPlayackInfo =
| { playbackInfo: PlaybackInfo; manifestMimeType: ManifestMimeType.Dash; manifest: DashManifest }
| { playbackInfo: PlaybackInfo; manifestMimeType: ManifestMimeType.Tidal; manifest: TidalManifest };

const playbackInfoMutex = new Mutex();
const playbackInfoSema = new Semaphore(1);
export const getPlaybackInfo = async (trackId: number, audioQuality: AudioQuality): 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");

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

Expand Down Expand Up @@ -72,6 +72,6 @@ export const getPlaybackInfo = async (trackId: number, audioQuality: AudioQualit
} catch (e) {
throw new Error(`Failed to decode Stream Info! ${(<Error>e)?.message}`);
} finally {
await playbackInfoMutex.unlock();
await playbackInfoSema.release();
}
};
1 change: 0 additions & 1 deletion plugins/LastFM/src/LastFM.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ export class LastFM {
headers: {
"Content-type": "application/x-www-form-urlencoded",
"Accept-Charset": "utf-8",
"User-Agent": navigator.userAgent,
},
method: "POST",
body: new URLSearchParams(params).toString(),
Expand Down
11 changes: 6 additions & 5 deletions plugins/LastFM/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,8 @@ const getCurrentTrack = async (playbackContext?: PlaybackContext): Promise<Curre
await mbidFromIsrc(trackItem?.item?.isrc).catch(undefinedError),
]);
let releaseAlbum;
if (recording?.id === undefined && album?.upc !== undefined) {
releaseAlbum = await releaseAlbumFromUpc(album.upc).catch(undefinedError);
if (recording?.id === undefined) {
releaseAlbum = await releaseAlbumFromUpc(album?.upc).catch(undefinedError);
if (releaseAlbum !== undefined) recording = await recordingFromAlbum(releaseAlbum, trackItem.item).catch(undefinedError);
}
const currentTrack = { trackItem: trackItem.item, playbackContext, playbackStart, recording, album, releaseAlbum };
Expand All @@ -153,12 +153,13 @@ const fetchJson = async <T>(url: string): Promise<T> => {
.then(rejectNotOk)
.then(toJson<T>));
};
const mbidFromIsrc = async (isrc?: string) => {
if (isrc !== undefined) return undefined;
const mbidFromIsrc = async (isrc: string | undefined) => {
if (isrc === undefined) return undefined;
const isrcData = await fetchJson<ISRCData>(`https://musicbrainz.org/ws/2/isrc/${isrc}?fmt=json`);
return isrcData?.recordings?.[0];
};
const releaseAlbumFromUpc = async (upc: string) => {
const releaseAlbumFromUpc = async (upc: string | undefined) => {
if (upc === undefined) return undefined;
const upcData = await fetchJson<UPCData>(`https://musicbrainz.org/ws/2/release/?query=barcode:${upc}&fmt=json`);
return upcData.releases?.[0];
};
Expand Down
6 changes: 6 additions & 0 deletions plugins/RealMAX/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions plugins/RealMAX/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
6 changes: 6 additions & 0 deletions plugins/RealMAX/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "RealMAX",
"description": "Always ensure that the highest quality available of a track is played",
"author": "Inrixia",
"main": "./src/index.js"
}
Loading

0 comments on commit b961aa8

Please sign in to comment.