diff --git a/plugins/lastfm/index.ts b/plugins/lastfm/index.ts index 7333541..786b134 100644 --- a/plugins/lastfm/index.ts +++ b/plugins/lastfm/index.ts @@ -6,6 +6,7 @@ import { LFM_API_KEY, } from "./cfg"; import { getAsset } from "./assets"; +import { LbWebsocket } from "./listenbrainz"; import { FluxStore } from "@uwu/shelter-defs"; const { @@ -30,12 +31,7 @@ const PresenceStore = storesFlat.PresenceStore as FluxStore<{ }[]; }>; -const FETCH_SHPROX_UA_HEADER = { - "X-Shprox-UA": - "ShelterLastFm/0.0.0 ( https://github.com/yellowsink/shelter-plugins )", -}; - -interface Track { +export interface Track { name: string; artist: string; album: string; @@ -94,138 +90,68 @@ const getScrobbleLastfm = async () => { } as Track; }; -// finds a MBID and adds it to a track if it doesnt exist -const listenBrainzLookupAdditional = async (basicTrack) => { - // following the behaviour of the webapp, if theres not an MBID, do a search. - if (!store.lbLookup) return; - if (basicTrack.additional_info?.release_mbid) return; - - try { - const metaRes = await fetch( - `https://shcors.uwu.network/https://api.listenbrainz.org/1/metadata/lookup/?${new URLSearchParams( - { - recording_name: basicTrack.track_name, - artist_name: basicTrack.artist_name, - metadata: "true", - inc: "artist tag release", - }, - )}`, - { headers: FETCH_SHPROX_UA_HEADER }, - ).then((r) => r.json()); - - basicTrack.additional_info = { ...basicTrack?.additional_info, ...metaRes }; - } catch (e) { - console.error( - "SHELTER LASTFM: finding listenbrainz MBID for track", - basicTrack, - "failed, ", - e, - ); - } -}; - -const getScrobbleListenbrainz = async () => { - // use the shelter proxy to set the user agent as required by musicbrainz - const nowPlayingRes = await fetch( - `https://shcors.uwu.network/https://api.listenbrainz.org/1/user/${store.user}/playing-now`, - { headers: FETCH_SHPROX_UA_HEADER }, - ).then((r) => r.json()); - - if (!nowPlayingRes.payload.count) return; - - const track = nowPlayingRes.payload.listens[0].track_metadata; - - await listenBrainzLookupAdditional(track); - - let albumArtUrl: string; - - if (track.additional_info?.release_mbid) { - // first check for release art and then for release group art - const relArtCheck = await fetch( - `https://coverartarchive.org/release/${track.additional_info?.release_mbid}/front`, - { method: "HEAD", redirect: "manual" }, - ); - if (relArtCheck.status !== 404) { - // ok fine we have album art for this release - albumArtUrl = `https://aart.yellows.ink/release/${track.additional_info.release_mbid}.webp`; - } else { - // okay, get the release group - const rgLookup = await fetch( - `https://shcors.uwu.network/https://musicbrainz.org/ws/2/release/${track.additional_info.release_mbid}?fmt=json&inc=release-groups`, - { headers: FETCH_SHPROX_UA_HEADER }, - ); - if (rgLookup.ok) { - const releaseJson = await rgLookup.json(); - - albumArtUrl = `https://aart.yellows.ink/release-group/${releaseJson["release-group"].id}.webp`; - } - } - } - - if (albumArtUrl) { - // test - const testRes = await fetch(albumArtUrl, { method: "HEAD" }); - if (!testRes.ok) albumArtUrl = undefined; - } - - return { - name: track.track_name, - artist: track.artist_name, - album: track.release_name, - albumArt: albumArtUrl, - url: track.additional_info?.recording_mbid - ? `https://musicbrainz.org/recording/${track.additional_info.recording_mbid}` - : `NOURL_${track.track_name}:${track.artist_name}:${track.release_name}`, - //date: "now", // not returned by api - nowPlaying: nowPlayingRes.payload.listens[0].playing_now, - } as Track; +const isSpotifyPlaying = () => { + for (const activity of PresenceStore.getActivities( + UserStore.getCurrentUser().id, + )) + if ( + activity?.type === ACTIVITY_TYPE_LISTENING && + activity.application_id !== DISCORD_APP_ID + ) + return true; + return false; }; let lastUrl: string; let startTimestamp: number; -const updateStatus = async () => { +const handleNewStatus = (track: Track) => { if (!store.user) return setPresence(); - if (store.ignoreSpotify) - for (const activity of PresenceStore.getActivities( - UserStore.getCurrentUser().id, - )) - if ( - activity?.type === ACTIVITY_TYPE_LISTENING && - activity.application_id !== DISCORD_APP_ID - ) - return setPresence(); + if (store.ignoreSpotify && isSpotifyPlaying()) return setPresence(); - const getFn = - store.service === "lbz" ? getScrobbleListenbrainz : getScrobbleLastfm; - - const lastTrack = await getFn(); - if (!lastTrack?.nowPlaying) { + if (!track?.nowPlaying) { startTimestamp = null; return setPresence(); } - if (lastTrack.url !== lastUrl || !startTimestamp) { + if (track.url !== lastUrl || !startTimestamp) { startTimestamp = Date.now(); } - lastUrl = lastTrack.url; + lastUrl = track.url; let appName = store.appName || DEFAULT_NAME; // screw it theres nothing wrong with eval okay??? // obviously im not serious on that but really this is fine -- sink appName = appName.replaceAll(/{{(.+)}}/g, (_, code) => - eval(`(c)=>{with(c){try{return ${code}}catch(e){return e}}}`)(lastTrack), + eval(`(c)=>{with(c){try{return ${code}}catch(e){return e}}}`)(track), ); - await setPresence(appName, lastTrack, startTimestamp); + return setPresence(appName, track, startTimestamp); +}; + +const updateStatusInterval = async () => { + if (!store.user) return setPresence(); + + if (store.ignoreSpotify && isSpotifyPlaying()) return setPresence(); + + /*const getFn = + store.service === "lbz" ? getScrobbleListenbrainz : getScrobbleLastfm; + + await handleNewStatus(await getFn());*/ + + // listenbrainz is handled by the websocket + if (store.service !== "lbz") await handleNewStatus(await getScrobbleLastfm()); }; -let interval; +let interval: number; const restartLoop = () => ( interval && clearInterval(interval), - (interval = setInterval(updateStatus, store.interval || DEFAULT_INTERVAL)) + (interval = setInterval( + updateStatusInterval, + store.interval || DEFAULT_INTERVAL, + )) ); const unpatch = shelter.patcher.after( @@ -248,9 +174,14 @@ const unpatch = shelter.patcher.after( }, ); +// start polling for last.fm restartLoop(); + +// start listenbrainz websocket, which will handle lifecycle all on its own. +const lbSocket = new LbWebsocket(handleNewStatus); + export const onUnload = () => ( - clearInterval(interval), setPresence(), unpatch() + clearInterval(interval), setPresence(), unpatch(), lbSocket.tearDownSocket() ); export * from "./Settings"; diff --git a/plugins/lastfm/listenbrainz.ts b/plugins/lastfm/listenbrainz.ts new file mode 100644 index 0000000..06325c7 --- /dev/null +++ b/plugins/lastfm/listenbrainz.ts @@ -0,0 +1,205 @@ +import type { Track } from "./index"; +import memoize from "lodash-es/memoize"; +import { io, Socket } from "socket.io-client"; + +const { store } = shelter.plugin; + +const { createEffect, createRoot } = shelter.solid; + +const FETCH_SHPROX_UA_HEADER = { + "X-Shprox-UA": + "ShelterLastFm/0.0.0 ( https://github.com/yellowsink/shelter-plugins )", +}; + +// finds a MBID and adds it to a track if it doesnt exist +export const listenBrainzLookupAdditional = async (basicTrack) => { + // following the behaviour of the webapp, if theres not an MBID, do a search. + if (!store.lbLookup) return; + if (basicTrack.additional_info?.release_mbid) return; + + try { + const metaRes = await fetch( + `https://shcors.uwu.network/https://api.listenbrainz.org/1/metadata/lookup/?${new URLSearchParams( + { + recording_name: basicTrack.track_name, + artist_name: basicTrack.artist_name, + metadata: "true", + inc: "artist tag release", + }, + )}`, + { headers: FETCH_SHPROX_UA_HEADER }, + ).then((r) => r.json()); + + basicTrack.additional_info = { ...basicTrack?.additional_info, ...metaRes }; + } catch (e) { + console.error( + "SHELTER LASTFM: finding listenbrainz MBID for track", + basicTrack, + "failed, ", + e, + ); + } +}; + +export const listenBrainzAlbumArtLookup = async (track) => { + let albumArtUrl: string; + + if (track.additional_info?.release_mbid) { + // first check for release art and then for release group art + const relArtCheck = await fetch( + `https://coverartarchive.org/release/${track.additional_info?.release_mbid}/front`, + { method: "HEAD", redirect: "manual" }, + ); + if (relArtCheck.status !== 404) { + // ok fine we have album art for this release + albumArtUrl = `https://aart.yellows.ink/release/${track.additional_info.release_mbid}.webp`; + } else { + // okay, get the release group + const rgLookup = await fetch( + `https://shcors.uwu.network/https://musicbrainz.org/ws/2/release/${track.additional_info.release_mbid}?fmt=json&inc=release-groups`, + { headers: FETCH_SHPROX_UA_HEADER }, + ); + if (rgLookup.ok) { + const releaseJson = await rgLookup.json(); + + albumArtUrl = `https://aart.yellows.ink/release-group/${releaseJson["release-group"].id}.webp`; + } + } + } + + if (albumArtUrl) { + // test + const testRes = await fetch(albumArtUrl, { method: "HEAD" }); + if (!testRes.ok) albumArtUrl = undefined; + } + + return albumArtUrl; +}; + +// don't repeat lookups for songs we've already seen before. +// it'd be more efficient to use the lastUrl test from the other file we have but that's annoying so lol this works + +const cacheKeySel = (song) => + `${song.track_name}|${song.artist_name}|${song.release_name}`; + +const additionalMemoized = memoize(listenBrainzLookupAdditional, cacheKeySel); + +const aartMemoized = memoize(listenBrainzAlbumArtLookup, cacheKeySel); + +const lbResToTrack = (res, aart: string, playNow) => + ({ + name: res.track_name, + artist: res.artist_name, + album: res.release_name, + albumArt: aart, + url: res.additional_info?.recording_mbid + ? `https://musicbrainz.org/recording/${res.additional_info.recording_mbid}` + : `NOURL_${res.track_name}:${res.artist_name}:${res.release_name}`, + //date: "now", // not returned by api + nowPlaying: playNow, + }) as Track; + +const getScrobbleListenbrainz = async () => { + // use the shelter proxy to set the user agent as required by musicbrainz + const nowPlayingRes = await fetch( + `https://shcors.uwu.network/https://api.listenbrainz.org/1/user/${store.user}/playing-now`, + { headers: FETCH_SHPROX_UA_HEADER }, + ).then((r) => r.json()); + + if (!nowPlayingRes?.payload?.count) return; + + const track = nowPlayingRes.payload.listens[0].track_metadata; + + await additionalMemoized(track); + + const albumArtUrl = await aartMemoized(track); + + return lbResToTrack( + track, + albumArtUrl, + nowPlayingRes.payload.listens[0].playing_now, + ); +}; + +export class LbWebsocket { + #socket: Socket; + #pendingSocket: Socket; + #lastUsername: string; + + #dispose: () => void; + + handler: (t: Track) => void; + + constructor(handler: (t: Track) => void) { + this.handler = handler; + + createRoot((DISPOSE) => { + this.#dispose = DISPOSE; + + createEffect(() => { + const nextUser = store.user?.toLowerCase?.(); + + if (store.service !== "lbz") this.tearDownSocket(false); + else if ( + this.#lastUsername !== nextUser || + (!this.#socket && !this.#pendingSocket) + ) { + //this.#startSocket(nextUser); + + // we won't get the current state, just future updates, so also do an imperative lookup + // this also lets us check the user is correct before starting a socket + getScrobbleListenbrainz() + .then(this.handler) + .then(() => this.#startSocket(nextUser)); + } + }); + }); + } + + #startSocket(nextUser: string) { + // lol this'll be fun when typing into the username box! + if (this.#pendingSocket) this.#pendingSocket.close(); + + // have to force ws only as polling won't proxy nicely. + this.#pendingSocket = io("https://shcors.uwu.network", { + path: "/https://listenbrainz.org/socket.io", + transports: ["websocket"], + }); + this.#pendingSocket.on("connect", () => this.#onConnect(nextUser)); + this.#pendingSocket.on("playing_now", (pn) => this.#playingNowHandler(pn)); + } + + #onConnect(nextUser: string) { + // replace the old socket + this.#socket?.close(); + this.#socket = this.#pendingSocket; + this.#pendingSocket = undefined; + this.#lastUsername = nextUser; + + // tell listenbrainz which user we care about + this.#socket.emit("json", { user: nextUser }); + } + + async #playingNowHandler(pn) { + const res = JSON.parse(pn); + const playingNowObject = res?.track_metadata; + if (!playingNowObject) return; + + // do extra meta lookup and album art lookup + await additionalMemoized(playingNowObject); + const albumArt = await aartMemoized(playingNowObject); + + // send resolved object onto handler for whatever is necessary + this.handler?.(lbResToTrack(playingNowObject, albumArt, res.playing_now)); + } + + tearDownSocket(dispose = true) { + this.#socket?.close(); + this.#pendingSocket?.close(); + + this.#socket = undefined; + this.#pendingSocket = undefined; + + if (dispose) this.#dispose?.(); + } +} diff --git a/plugins/lastfm/package.json b/plugins/lastfm/package.json new file mode 100644 index 0000000..12a736d --- /dev/null +++ b/plugins/lastfm/package.json @@ -0,0 +1 @@ +{"dependencies":{"@types/lodash-es":"^4.17.12","lodash-es":"^4.17.21","socket.io-client":"^4.8.1"}} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6aa657c..9aef71d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,18 @@ importers: specifier: ^0.2.0 version: 0.2.0 + plugins/lastfm: + dependencies: + '@types/lodash-es': + specifier: ^4.17.12 + version: 4.17.12 + lodash-es: + specifier: ^4.17.21 + version: 4.17.21 + socket.io-client: + specifier: ^4.8.1 + version: 4.8.1 + plugins/snazzy-shelter: dependencies: '@tanstack/solid-virtual': @@ -37,7 +49,7 @@ importers: version: 3.0.0-beta.6 '@uwu/monaco-solid': specifier: ^1.1.0 - version: 1.1.0(solid-js@1.6.16) + version: 1.1.0(solid-js@1.8.15) fuse.js: specifier: ^6.6.2 version: 6.6.2 @@ -255,10 +267,19 @@ packages: '@reach/observe-rect@1.2.0': resolution: {integrity: sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==} + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@tanstack/solid-virtual@3.0.0-beta.6': resolution: {integrity: sha512-/HjeHZb4UZxxFSAkICUEWOozGwHQpKlvtnUoS5uSMSuLOz0XM5vFq6zR6ENwAczKWDtkh8ntddk+zXAhyXOlEw==} engines: {node: '>=12'} + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.13': + resolution: {integrity: sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==} + '@uwu/lune@1.4.1': resolution: {integrity: sha512-CETPNeFqOlCmeS7julFW8b9VWDaaqV67vnSOAv92EoDNoC98/faGJuXW047LtsJLkMkcK+Z72GHhJ9o4qp/+5A==} hasBin: true @@ -300,6 +321,22 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + engine.io-client@6.6.2: + resolution: {integrity: sha512-TAr+NKeoVTjEVW8P3iHguO1LO6RlUz9O5Y8o7EY0fU+gY1NYqas7NN3slpFtbXEsLMHk0h90fJMfKjRkQ0qUIw==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + esbuild-android-64@0.14.54: resolution: {integrity: sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==} engines: {node: '>=12'} @@ -509,6 +546,9 @@ packages: monaco-editor@0.33.0: resolution: {integrity: sha512-VcRWPSLIUEgQJQIE0pVT8FcGBIgFoxz7jtqctE+IiCxWugD0DwgyQBcZBhdSrdMC84eumoqMZsGl2GTreOzwqw==} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.7: resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -593,6 +633,15 @@ packages: shiki-es@0.2.0: resolution: {integrity: sha512-RbRMD+IuJJseSZljDdne9ThrUYrwBwJR04FvN4VXpfsU3MNID5VJGHLAD5je/HGThCyEKNgH+nEkSFEWKD7C3Q==} + deprecated: Please migrate to https://github.com/antfu/shikiji + + socket.io-client@4.8.1: + resolution: {integrity: sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.4: + resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} + engines: {node: '>=10.0.0'} solid-js@1.6.16: resolution: {integrity: sha512-Ng4CahvLlpGA3BXIMiiMdwhI8WpObZ8gXqm97GCKR4+MpnODs6Pdpco+tmVCY/4ZDFaEKDxz7WRLAAdoXdlY7w==} @@ -639,6 +688,22 @@ packages: utf-8-validate: optional: true + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + snapshots: '@biomejs/biome@1.5.3': @@ -752,10 +817,18 @@ snapshots: '@reach/observe-rect@1.2.0': {} + '@socket.io/component-emitter@3.1.2': {} + '@tanstack/solid-virtual@3.0.0-beta.6': dependencies: '@reach/observe-rect': 1.2.0 + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.13 + + '@types/lodash@4.17.13': {} + '@uwu/lune@1.4.1': dependencies: chokidar: 3.6.0 @@ -769,11 +842,11 @@ snapshots: - bufferutil - utf-8-validate - '@uwu/monaco-solid@1.1.0(solid-js@1.6.16)': + '@uwu/monaco-solid@1.1.0(solid-js@1.8.15)': dependencies: '@monaco-editor/loader': 1.3.2(monaco-editor@0.33.0) monaco-editor: 0.33.0 - solid-js: 1.6.16 + solid-js: 1.8.15 '@uwu/shelter-defs@1.4.0': dependencies: @@ -812,6 +885,24 @@ snapshots: csstype@3.1.3: {} + debug@4.3.7: + dependencies: + ms: 2.1.3 + + engine.io-client@6.6.2: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + engine.io-parser: 5.2.3 + ws: 8.17.1 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + esbuild-android-64@0.14.54: optional: true @@ -984,6 +1075,8 @@ snapshots: monaco-editor@0.33.0: {} + ms@2.1.3: {} + nanoid@3.3.7: {} normalize-path@3.0.0: {} @@ -1064,6 +1157,24 @@ snapshots: shiki-es@0.2.0: {} + socket.io-client@4.8.1: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + engine.io-client: 6.6.2 + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.4: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + transitivePeerDependencies: + - supports-color + solid-js@1.6.16: dependencies: csstype: 3.1.3 @@ -1093,3 +1204,7 @@ snapshots: util-deprecate@1.0.2: {} ws@8.16.0: {} + + ws@8.17.1: {} + + xmlhttprequest-ssl@2.1.2: {}