From 8bab6bc0d81259447254c67c1ceb9b5c1df17f5c Mon Sep 17 00:00:00 2001 From: Inrixia Date: Thu, 20 Jun 2024 23:48:40 +1200 Subject: [PATCH] Migrate MusicBrainz data into unified class --- lib/TrackCache/ExtendedTrackItem.ts | 73 +++++++++++ lib/{ => TrackCache}/TrackItemCache.ts | 18 +-- lib/musicbrainzApi/index.ts | 28 ++++ .../musicbrainzApi/types}/ISRCData.ts | 0 .../musicbrainzApi/types}/Recording.ts | 1 + .../musicbrainzApi/types}/ReleaseData.ts | 0 .../musicbrainzApi/types}/UPCData.ts | 0 lib/undefinedError.ts | 4 + plugins/LastFM/src/index.ts | 120 ++++++------------ plugins/RealMAX/src/index.ts | 8 +- plugins/SongDownloader/src/index.ts | 2 +- plugins/TidalTags/src/index.ts | 2 +- 12 files changed, 160 insertions(+), 96 deletions(-) create mode 100644 lib/TrackCache/ExtendedTrackItem.ts rename lib/{ => TrackCache}/TrackItemCache.ts (52%) create mode 100644 lib/musicbrainzApi/index.ts rename {plugins/LastFM/src/types/musicbrainz => lib/musicbrainzApi/types}/ISRCData.ts (100%) rename {plugins/LastFM/src/types/musicbrainz => lib/musicbrainzApi/types}/Recording.ts (89%) rename {plugins/LastFM/src/types/musicbrainz => lib/musicbrainzApi/types}/ReleaseData.ts (100%) rename {plugins/LastFM/src/types/musicbrainz => lib/musicbrainzApi/types}/UPCData.ts (100%) create mode 100644 lib/undefinedError.ts diff --git a/lib/TrackCache/ExtendedTrackItem.ts b/lib/TrackCache/ExtendedTrackItem.ts new file mode 100644 index 0000000..10ee1a6 --- /dev/null +++ b/lib/TrackCache/ExtendedTrackItem.ts @@ -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 = {}; + + 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 { + 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) => res?.[0].album)); + } + public async recording(): Promise { + 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(), + }; + } +} diff --git a/lib/TrackItemCache.ts b/lib/TrackCache/TrackItemCache.ts similarity index 52% rename from lib/TrackItemCache.ts rename to lib/TrackCache/TrackItemCache.ts index 03e6a8b..04917f3 100644 --- a/lib/TrackItemCache.ts +++ b/lib/TrackCache/TrackItemCache.ts @@ -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 = new Map(); - public static get(trackId: number | string | undefined) { + private static readonly _cache: Record = {}; + 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 = 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; } } diff --git a/lib/musicbrainzApi/index.ts b/lib/musicbrainzApi/index.ts new file mode 100644 index 0000000..2062c11 --- /dev/null +++ b/lib/musicbrainzApi/index.ts @@ -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 = {}; +const fetchCachedJson = async (url: string): Promise => + _jsonCache[url] ?? + (_jsonCache[url] = requestStream(url) + .then(rejectNotOk) + .then(toJson)); + +export class MusicBrainz { + public static async getRecording(isrc?: string) { + if (isrc === undefined) return undefined; + const isrcData = await fetchCachedJson(`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(`https://musicbrainz.org/ws/2/release/?query=barcode:${upc}&fmt=json`); + } + public static async getAlbumRelease(albumId?: number) { + if (albumId === undefined) return undefined; + return fetchCachedJson(`https://musicbrainz.org/ws/2/release/${albumId}?inc=recordings+isrcs&fmt=json`); + } +} diff --git a/plugins/LastFM/src/types/musicbrainz/ISRCData.ts b/lib/musicbrainzApi/types/ISRCData.ts similarity index 100% rename from plugins/LastFM/src/types/musicbrainz/ISRCData.ts rename to lib/musicbrainzApi/types/ISRCData.ts diff --git a/plugins/LastFM/src/types/musicbrainz/Recording.ts b/lib/musicbrainzApi/types/Recording.ts similarity index 89% rename from plugins/LastFM/src/types/musicbrainz/Recording.ts rename to lib/musicbrainzApi/types/Recording.ts index c0a291a..c66c4fa 100644 --- a/plugins/LastFM/src/types/musicbrainz/Recording.ts +++ b/lib/musicbrainzApi/types/Recording.ts @@ -5,4 +5,5 @@ export interface Recording { id?: string; disambiguation?: string; video?: boolean; + isrcs?: string[]; } diff --git a/plugins/LastFM/src/types/musicbrainz/ReleaseData.ts b/lib/musicbrainzApi/types/ReleaseData.ts similarity index 100% rename from plugins/LastFM/src/types/musicbrainz/ReleaseData.ts rename to lib/musicbrainzApi/types/ReleaseData.ts diff --git a/plugins/LastFM/src/types/musicbrainz/UPCData.ts b/lib/musicbrainzApi/types/UPCData.ts similarity index 100% rename from plugins/LastFM/src/types/musicbrainz/UPCData.ts rename to lib/musicbrainzApi/types/UPCData.ts diff --git a/lib/undefinedError.ts b/lib/undefinedError.ts new file mode 100644 index 0000000..91fcb6e --- /dev/null +++ b/lib/undefinedError.ts @@ -0,0 +1,4 @@ +export const undefinedError = (err: Error) => { + console.error(err); + return undefined; +}; diff --git a/plugins/LastFM/src/index.ts b/plugins/LastFM/src/index.ts index 549cace..ab82836 100644 --- a/plugins/LastFM/src/index.ts +++ b/plugins/LastFM/src/index.ts @@ -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; @@ -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 = [ @@ -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}`); } @@ -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), @@ -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; @@ -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 => { const playbackStart = Date.now(); - const state = store.getState(); - playbackContext ??= state.playbackControls.playbackContext; + playbackContext ??= store.getState().playbackControls.playbackContext; if (!playbackContext) throw new Error("No playbackContext found"); - const mediaItems: Record = 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 = {}; -const fetchJson = async (url: string): Promise => { - const jsonData = _jsonCache[url]; - if (jsonData !== undefined) return jsonData as T; - return (_jsonCache[url] = await requestStream(url) - .then(rejectNotOk) - .then(toJson)); -}; -const mbidFromIsrc = async (isrc: string | undefined) => { - if (isrc === undefined) return undefined; - const isrcData = await fetchJson(`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(`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(`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()); diff --git a/plugins/RealMAX/src/index.ts b/plugins/RealMAX/src/index.ts index dba4f5c..80ed761 100644 --- a/plugins/RealMAX/src/index.ts +++ b/plugins/RealMAX/src/index.ts @@ -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"; @@ -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> = {}; public static async getMaxId(itemId: ItemId | undefined): Promise { 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); @@ -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 }); diff --git a/plugins/SongDownloader/src/index.ts b/plugins/SongDownloader/src/index.ts index 0239ad2..1214bfb 100644 --- a/plugins/SongDownloader/src/index.ts +++ b/plugins/SongDownloader/src/index.ts @@ -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; const downloadButtons: DownloadButtoms = {}; diff --git a/plugins/TidalTags/src/index.ts b/plugins/TidalTags/src/index.ts index c76390e..0de3ae2 100644 --- a/plugins/TidalTags/src/index.ts +++ b/plugins/TidalTags/src/index.ts @@ -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";