Skip to content

Commit

Permalink
Migrate MusicBrainz data into unified class
Browse files Browse the repository at this point in the history
  • Loading branch information
Inrixia committed Jun 20, 2024
1 parent b961aa8 commit 8bab6bc
Show file tree
Hide file tree
Showing 12 changed files with 160 additions and 96 deletions.
73 changes: 73 additions & 0 deletions lib/TrackCache/ExtendedTrackItem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { actions } from "@neptune";
import { ItemId, TrackItem, Album } from "neptune-types/tidal";
import { interceptPromise } from "../interceptPromise";
import { MusicBrainz } from "../musicbrainzApi";
import { Recording } from "../musicbrainzApi/types/Recording";
import { Release } from "../musicbrainzApi/types/UPCData";
import { TrackItemCache } from "./TrackItemCache";
import { undefinedError } from "../undefinedError";

export class ExtendedTrackItem {
public readonly trackId: ItemId;
private _trackItem?: TrackItem;
private _album?: Album;
private _recording?: Recording;
private _releaseAlbum?: Release;

private static readonly _cache: Record<ItemId, ExtendedTrackItem> = {};

private constructor(trackId: ItemId) {
this.trackId = trackId;
}

public static get(trackId: ItemId) {
if (trackId === undefined) return undefined;
return this._cache[trackId] ?? (this._cache[trackId] = new this(trackId));
}

public trackItem(): TrackItem | undefined {
if (this._trackItem !== undefined) return this._trackItem;

return (this._trackItem = TrackItemCache.get(this.trackId));
}
public async album(): Promise<Album | undefined> {
if (this._album !== undefined) return this._album;

actions.content.loadAlbum({ albumId: this.trackItem()?.album?.id! });
return (this._album = await interceptPromise(["content/LOAD_ALBUM_SUCCESS"], []).then((res) => <Album>res?.[0].album));
}
public async recording(): Promise<Recording | undefined> {
if (this._recording !== undefined) return this._recording;

this._recording = await MusicBrainz.getRecording(this.trackItem()?.isrc).catch(undefinedError);
if (this._recording !== undefined) return this._recording;

const trackItem = this.trackItem();
if (trackItem === undefined) return undefined;

const album = await this.album();
const albumRelease = await MusicBrainz.getAlbumRelease(album?.id).catch(undefinedError);

const volumeNumber = (trackItem.volumeNumber ?? 1) - 1;
const trackNumber = (trackItem.trackNumber ?? 1) - 1;

return (this._recording = albumRelease?.media?.[volumeNumber]?.tracks?.[trackNumber]?.recording);
}
public async releaseAlbum() {
if (this._releaseAlbum !== undefined) return this._releaseAlbum;

const album = await this.album();
const upcData = await MusicBrainz.getUPCData(album?.upc).catch(undefinedError);

return (this._releaseAlbum = upcData?.releases?.[0]);
}

public async everything() {
return {
trackItem: this.trackItem(),
album: await this.album(),
releaseAlbum: await this.releaseAlbum(),
recording: await this.recording(),
};
}
}
18 changes: 10 additions & 8 deletions lib/TrackItemCache.ts → lib/TrackCache/TrackItemCache.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import { store } from "@neptune";
import { TrackItem, MediaItem } from "neptune-types/tidal";

import { TrackItem, MediaItem, ItemId } from "neptune-types/tidal";
import { undefinedError } from "../undefinedError";
export class TrackItemCache {
private static readonly _cache: Map<string, TrackItem> = new Map<string, TrackItem>();
public static get(trackId: number | string | undefined) {
private static readonly _cache: Record<ItemId, TrackItem> = {};
public static get(trackId?: ItemId) {
if (trackId === undefined) return undefined;
trackId = trackId.toString();
let mediaItem = TrackItemCache._cache.get(trackId);

let mediaItem = this._cache[trackId];
if (mediaItem !== undefined) return mediaItem;

const mediaItems: Record<number, MediaItem> = store.getState().content.mediaItems;
for (const itemId in mediaItems) {
const item = mediaItems[itemId]?.item;
if (item?.contentType !== "track") continue;
TrackItemCache._cache.set(itemId, item);
this._cache[itemId] = item;
}
mediaItem = TrackItemCache._cache.get(trackId);

mediaItem = this._cache[trackId];
if (mediaItem !== undefined) return mediaItem;
}
}
28 changes: 28 additions & 0 deletions lib/musicbrainzApi/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { MediaItem } from "neptune-types/tidal";
import { requestStream, rejectNotOk, toJson } from "../fetch";
import type { ISRCData } from "./types/ISRCData";
import type { ReleaseData } from "./types/ReleaseData";
import type { UPCData, Release } from "./types/UPCData";

const _jsonCache: Record<string, unknown> = {};
const fetchCachedJson = async <T>(url: string): Promise<T> =>
<T>_jsonCache[url] ??
(_jsonCache[url] = requestStream(url)
.then(rejectNotOk)
.then(toJson<T>));

export class MusicBrainz {
public static async getRecording(isrc?: string) {
if (isrc === undefined) return undefined;
const isrcData = await fetchCachedJson<ISRCData>(`https://musicbrainz.org/ws/2/isrc/${isrc}?fmt=json`);
return isrcData?.recordings?.[0];
}
public static async getUPCData(upc?: string) {
if (upc === undefined) return undefined;
return fetchCachedJson<UPCData>(`https://musicbrainz.org/ws/2/release/?query=barcode:${upc}&fmt=json`);
}
public static async getAlbumRelease(albumId?: number) {
if (albumId === undefined) return undefined;
return fetchCachedJson<ReleaseData>(`https://musicbrainz.org/ws/2/release/${albumId}?inc=recordings+isrcs&fmt=json`);
}
}
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export interface Recording {
id?: string;
disambiguation?: string;
video?: boolean;
isrcs?: string[];
}
File renamed without changes.
File renamed without changes.
4 changes: 4 additions & 0 deletions lib/undefinedError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const undefinedError = (err: Error) => {
console.error(err);
return undefined;
};
120 changes: 38 additions & 82 deletions plugins/LastFM/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,19 @@
import { actions, intercept, store } from "@neptune";
import { PlaybackContext } from "../../../lib/AudioQualityTypes";
import { rejectNotOk, requestStream, toJson } from "../../../lib/fetch";

import { LastFM, ScrobbleOpts } from "./LastFM";

import type { Album, MediaItem, TrackItem } from "neptune-types/tidal";
import type { TrackItem } from "neptune-types/tidal";
import { messageError, messageInfo } from "../../../lib/messageLogging";
import { interceptPromise } from "../../../lib/interceptPromise";

import type { Release, UPCData } from "./types/musicbrainz/UPCData";
import type { ISRCData } from "./types/musicbrainz/ISRCData";
import type { ReleaseData } from "./types/musicbrainz/ReleaseData";
import { fullTitle } from "../../../lib/fullTitle";
import { Recording } from "./types/musicbrainz/Recording";

export { Settings } from "./Settings";

// @ts-expect-error Remove this when types are available
import { storage } from "@plugin";
import { undefinedError } from "../../../lib/undefinedError";
import { ExtendedTrackItem } from "../../../lib/TrackCache/ExtendedTrackItem";

let totalPlayTime = 0;
let lastPlayStart: number | null = null;
Expand All @@ -26,16 +22,15 @@ const MIN_SCROBBLE_DURATION = 240000; // 4 minutes in milliseconds
const MIN_SCROBBLE_PERCENTAGE = 0.5; // Minimum percentage of song duration required to scrobble

let currentTrack: CurrentTrack;
const updateNowPlaying = (playbackContext?: PlaybackContext) =>
getCurrentTrack(playbackContext)
.then((_currentTrack) => {
const nowPlayingParams = getTrackParams((currentTrack = _currentTrack));
console.log("[last.fm] updatingNowPlaying", nowPlayingParams);
LastFM.updateNowPlaying(nowPlayingParams)
.catch((err) => messageError(`last.fm - Failed to updateNowPlaying! ${err}`))
.then((res) => console.log("[last.fm] updatedNowPlaying", res));
})
.catch(undefinedError);
const updateNowPlaying = async (playbackContext?: PlaybackContext) => {
currentTrack = await getCurrentTrack(playbackContext);
const nowPlayingParams = await getTrackParams(currentTrack);
console.log("[last.fm] updatingNowPlaying", nowPlayingParams);
return LastFM.updateNowPlaying(nowPlayingParams)
.catch((err) => messageError(`last.fm - Failed to updateNowPlaying! ${err}`))
.then((res) => console.log("[last.fm] updatedNowPlaying", res));
};

actions.lastFm.disconnect();

const intercepters = [
Expand All @@ -58,13 +53,15 @@ const intercepters = [
const minPlayTime = +currentTrack.playbackContext.actualDuration * MIN_SCROBBLE_PERCENTAGE * 1000;
const moreThan50Percent = totalPlayTime >= minPlayTime;
if (longerThan4min || moreThan50Percent) {
const scrobbleParams = getTrackParams(currentTrack);
console.log("[last.fm] scrobbling", scrobbleParams);
LastFM.scrobble(scrobbleParams)
.catch((err) => messageError(`last.fm - Failed to scrobble! ${err}`))
.then((res) => console.log("[last.fm] scrobbled", res));
getTrackParams(currentTrack).then((scrobbleParams) => {
console.log("[last.fm] scrobbling", scrobbleParams);
LastFM.scrobble(scrobbleParams)
.catch((err) => messageError(`last.fm - Failed to scrobble! ${err}`))
.then((res) => console.log("[last.fm] scrobbled", res));
});
} else {
const noScrobbleMessage = `skipped scrobbling ${currentTrack.trackItem.title} - Listened for ${(totalPlayTime / 1000).toFixed(0)}s, need ${(minPlayTime / 1000).toFixed(0)}s`;
const trackTitle = currentTrack.extTrackItem.trackItem()?.title;
const noScrobbleMessage = `skipped scrobbling ${trackTitle} - Listened for ${(totalPlayTime / 1000).toFixed(0)}s, need ${(minPlayTime / 1000).toFixed(0)}s`;
console.log(`[last.fm] ${noScrobbleMessage}`);
if (storage.displaySkippedScrobbles) messageInfo(`last.fm - ${noScrobbleMessage}`);
}
Expand All @@ -76,12 +73,14 @@ const intercepters = [
}),
];

const getTrackParams = ({ trackItem, playbackContext, playbackStart, album, recording, releaseAlbum }: CurrentTrack) => {
const getTrackParams = async ({ extTrackItem, playbackContext, playbackStart }: CurrentTrack) => {
const { trackItem, releaseAlbum, recording, album } = await extTrackItem.everything();

let artist;
const sharedAlbumArtist = trackItem.artists?.find((artist) => artist?.id === album?.artist?.id);
if (sharedAlbumArtist?.name !== undefined) artist = formatArtists([sharedAlbumArtist?.name]);
else if (trackItem.artist?.name !== undefined) artist = formatArtists([trackItem.artist?.name]);
else if ((trackItem.artists?.length ?? -1) > 0) artist = formatArtists(trackItem.artists?.map(({ name }) => name));
const sharedAlbumArtist = trackItem?.artists?.find((artist) => artist?.id === album?.artist?.id);
if (sharedAlbumArtist?.name !== undefined) artist = formatArtists([sharedAlbumArtist.name]);
else if (trackItem?.artist?.name !== undefined) artist = formatArtists([trackItem.artist.name]);
else if ((trackItem?.artists?.length ?? -1) > 0) artist = formatArtists(trackItem?.artists?.map(({ name }) => name));

const params: ScrobbleOpts = {
track: recording?.title ?? fullTitle(<TrackItem>trackItem),
Expand All @@ -91,15 +90,15 @@ const getTrackParams = ({ trackItem, playbackContext, playbackStart, album, reco

if (!!recording?.id) params.mbid = recording.id;

if (!!album?.artist?.name) params.albumArtist = album?.artist?.name;
if (!!album?.artist?.name) params.albumArtist = album.artist.name;
else if ((album?.artists?.length ?? -1) > 0) params.albumArtist = formatArtists(album?.artists?.map(({ name }) => name));

if (!!releaseAlbum?.title) {
params.album = releaseAlbum?.title;
if (!!releaseAlbum.disambiguation) params.album += ` (${releaseAlbum.disambiguation})`;
} else if (!!trackItem.album?.title) params.album = trackItem.album.title;
} else if (!!trackItem?.album?.title) params.album = trackItem.album.title;

if (!!trackItem.trackNumber) params.trackNumber = trackItem.trackNumber.toString();
if (!!trackItem?.trackNumber) params.trackNumber = trackItem.trackNumber.toString();
if (!!playbackContext.actualDuration) params.duration = playbackContext.actualDuration.toFixed(0);

return params;
Expand All @@ -109,66 +108,23 @@ const formatArtists = (artists?: (string | undefined)[]) => {
return artist.split(", ")[0];
};

const undefinedError = (err: Error) => {
console.error(err);
return undefined;
};
type CurrentTrack = {
trackItem: MediaItem["item"];
extTrackItem: ExtendedTrackItem;
playbackContext: PlaybackContext;
playbackStart: number;
album?: Album;
recording?: Recording;
releaseAlbum?: Release;
};
const getCurrentTrack = async (playbackContext?: PlaybackContext): Promise<CurrentTrack> => {
const playbackStart = Date.now();
const state = store.getState();
playbackContext ??= <PlaybackContext>state.playbackControls.playbackContext;
playbackContext ??= <PlaybackContext>store.getState().playbackControls.playbackContext;
if (!playbackContext) throw new Error("No playbackContext found");
const mediaItems: Record<number, MediaItem> = state.content.mediaItems;
const trackItem = mediaItems[+playbackContext.actualProductId];
actions.content.loadAlbum({ albumId: trackItem?.item?.album?.id! });
let [album, recording] = await Promise.all([
await interceptPromise(["content/LOAD_ALBUM_SUCCESS"], [])
.catch(undefinedError)
.then((res) => res?.[0].album),
await mbidFromIsrc(trackItem?.item?.isrc).catch(undefinedError),
]);
let releaseAlbum;
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 };

const extTrackItem = ExtendedTrackItem.get(playbackContext.actualProductId);
if (extTrackItem === undefined) throw new Error("Failed to get extTrackItem");

const currentTrack = { extTrackItem, playbackContext, playbackStart };
console.log("[last.fm] getCurrentTrack", currentTrack);
return currentTrack;
};

const _jsonCache: Record<string, unknown> = {};
const fetchJson = async <T>(url: string): Promise<T> => {
const jsonData = _jsonCache[url];
if (jsonData !== undefined) return jsonData as T;
return (_jsonCache[url] = await requestStream(url)
.then(rejectNotOk)
.then(toJson<T>));
};
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 | 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];
};
const recordingFromAlbum = async (releaseAlbum: Release, trackItem: MediaItem["item"]) => {
if (releaseAlbum?.id === undefined) return undefined;
const albumReleaseData = await fetchJson<ReleaseData>(`https://musicbrainz.org/ws/2/release/${releaseAlbum.id}?inc=recordings&fmt=json`);
const albumTracks = albumReleaseData.media?.[(trackItem.volumeNumber ?? 1) - 1].tracks;
const albumTrackRelease = albumTracks?.[trackItem.trackNumber! - 1];
return albumTrackRelease?.recording;
return currentTrack;
};

export const onUnload = () => intercepters.forEach((unload) => unload());
Expand Down
8 changes: 4 additions & 4 deletions plugins/RealMAX/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ItemId, TrackItem } from "neptune-types/tidal";
import { TrackItemCache } from "../../../lib/TrackItemCache";
import { TrackItemCache } from "../../../lib/TrackCache/TrackItemCache";
import { fetchIsrcIterable } from "../../../lib/tidalDevApi/isrc";
import { actions, intercept, store } from "@neptune";
import { PlaybackContext } from "../../../lib/AudioQualityTypes";
Expand All @@ -10,12 +10,12 @@ const hasHiRes = (trackItem: TrackItem) => {
return tags.findIndex((tag) => tag === "HIRES_LOSSLESS") !== -1;
};

class QueueCleaner {
class MaxTrack {
private static readonly _idMap: Record<ItemId, Promise<ItemId | false>> = {};
public static async getMaxId(itemId: ItemId | undefined): Promise<ItemId | false> {
if (itemId === undefined) return false;

const idMapping = QueueCleaner._idMap[itemId];
const idMapping = MaxTrack._idMap[itemId];
if (idMapping !== undefined) return idMapping;

const trackItem = TrackItemCache.get(itemId);
Expand All @@ -37,7 +37,7 @@ const unloadPlay = intercept("playbackControls/MEDIA_PRODUCT_TRANSITION", async
for (let index = currentIndex; index < Math.min(elements.length - 1, currentIndex + 5); index++) {
const mediaItemId = elements[index]?.mediaItemId;
if (mediaItemId === undefined) return;
const maxId = await QueueCleaner.getMaxId(mediaItemId);
const maxId = await MaxTrack.getMaxId(mediaItemId);
const maxInjected = elements[index + 1]?.mediaItemId === maxId;
if (maxInjected) {
actions.playQueue.removeAtIndex({ index: index + 1 });
Expand Down
2 changes: 1 addition & 1 deletion plugins/SongDownloader/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { messageError } from "../../../lib/messageLogging";
import { addMetadata } from "./addMetadata";
import { fileNameFromInfo } from "./lib/fileName";
import { toBuffer } from "../../../lib/fetch";
import { TrackItemCache } from "../../../lib/TrackItemCache";
import { TrackItemCache } from "../../../lib/TrackCache/TrackItemCache";

type DownloadButtoms = Record<string, HTMLButtonElement>;
const downloadButtons: DownloadButtoms = {};
Expand Down
2 changes: 1 addition & 1 deletion plugins/TidalTags/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export { Settings } from "./Settings";
import { storage } from "@plugin";
import { isElement } from "./lib/isElement";
import { setInfoColumnHeaders, setInfoColumns } from "./setInfoColumns";
import { TrackItemCache } from "../../../lib/TrackItemCache";
import { TrackItemCache } from "../../../lib/TrackCache/TrackItemCache";
import { PlaybackContext } from "../../../lib/AudioQualityTypes";
import { getHeaders } from "../../../lib/fetch";

Expand Down

0 comments on commit 8bab6bc

Please sign in to comment.