A music player that connects to your cloud/distributed storage. diffuse.sh
Something went wrong. Try again.
7.9 kB · 278 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279import { md5 } from "@noble/hashes/legacy.js";import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
import { BroadcastableDiffuseElement, defineElement,} from "~/common/element.js";import { computed, signal } from "~/common/signal.js";import { clearSession, readSession, saveSession } from "../session.js";
/** * @import {Track} from "~/definitions/types.d.ts" * @import {ScrobbleElement} from "@specs/components/supplement/types.d.ts" */
////////////////////////////////////////////// CONSTANTS////////////////////////////////////////////
const LASTFM_API_URL = "https://ws.audioscrobbler.com/2.0/";const LASTFM_AUTH_URL = "https://www.last.fm/api/auth/";const STORAGE_KEY = "diffuse/supplement/last.fm/session";
const DEFAULT_API_KEY = "4f0fe85b67baef8bb7d008a8754a95e5";const DEFAULT_API_SECRET = "0cec3ca0f58e04a5082f1131aba1e0d3";
////////////////////////////////////////////// ELEMENT////////////////////////////////////////////
/** * @implements {ScrobbleElement} */class LastFmScrobbler extends BroadcastableDiffuseElement { static NAME = "diffuse/supplement/last.fm";
get #apiKey() { return this.getAttribute("api-key") ?? DEFAULT_API_KEY; }
get #apiSecret() { return this.getAttribute("api-secret") ?? DEFAULT_API_SECRET; }
// SIGNALS
#handle = signal(/** @type {string | null} */ (null)); #sessionKey = signal(/** @type {string | null} */ (null)); #isAuthenticating = signal(false);
// STATE
handle = this.#handle.get; isAuthenticated = computed(() => this.#sessionKey.value !== null); isAuthenticating = this.#isAuthenticating.get;
// LIFECYCLE
/** @override */ connectedCallback() { // Broadcast if needed if (this.hasAttribute("group")) { const actions = this.broadcast(this.identifier, { nowPlaying: { strategy: "leaderOnly", fn: this.nowPlaying }, scrobble: { strategy: "leaderOnly", fn: this.scrobble },
setHandle: { strategy: "replicate", fn: this.#handle.set }, setSession: { strategy: "replicate", fn: this.#sessionKey.set }, });
if (actions) { this.nowPlaying = actions.nowPlaying; this.scrobble = actions.scrobble;
this.#handle.set = actions.setHandle; this.#sessionKey.set = actions.setSession; } }
super.connectedCallback();
this.#tryRestore(); }
async #tryRestore() { await this.whenConnected();
// last.fm appends ?token=TOKEN to the callback URL after authorization. const urlParams = new URLSearchParams(location.search); const urlToken = urlParams.get("token");
if (urlToken) { urlParams.delete("token"); const newSearch = urlParams.toString(); history.replaceState( null, "", location.pathname + (newSearch ? "?" + newSearch : "") + location.hash, );
this.#isAuthenticating.set(true);
try { const session = await this.#getSession(urlToken); await this.#setSession(session); } catch (err) { console.warn("last.fm: failed to exchange token for session", err); } finally { this.#isAuthenticating.set(false); }
return; }
const stored = await readSession(this, STORAGE_KEY);
if (stored) { try { const { key, name: handle } = JSON.parse(stored); if (await this.isLeader()) { this.#sessionKey.set(key); this.#handle.set(handle); } else { this.#sessionKey.value = key; this.#handle.value = handle; } } catch { await clearSession(this, STORAGE_KEY); } } }
// AUTH
/** * Initiate the last.fm auth flow. * Redirects the browser to the authorization page; last.fm appends ?token=TOKEN to the callback. */ signIn() { const callbackUrl = location.origin + location.pathname + location.search; const authUrl = new URL(LASTFM_AUTH_URL); authUrl.searchParams.set("api_key", this.#apiKey); authUrl.searchParams.set("cb", callbackUrl);
// Navigate the top-level frame so last.fm's X-Frame-Options doesn't block loading // when this element is used inside an iframe. (window.top ?? window).location.assign(authUrl.toString()); }
/** * Clear the stored session. */ async signOut() { this.#sessionKey.set(null); this.#handle.set(null); await clearSession(this, STORAGE_KEY); }
/** @param {{ key: string, name: string }} session */ async #setSession({ key, name: handle }) { this.#sessionKey.set(key); this.#handle.set(handle); await saveSession(this, STORAGE_KEY, JSON.stringify({ key, name: handle })); }
// SCROBBLE ACTIONS
/** * @param {Track} track */ async nowPlaying(track) { const tags = track.tags ?? {}; /** @type {Record<string, string>} */ const params = {};
if (tags.title) params.track = tags.title; if (tags.artist) params.artist = tags.artist; if (tags.album) params.album = tags.album; if (tags.albumartist) params.albumArtist = tags.albumartist; if (tags.track?.no != null) params.trackNumber = String(tags.track.no); if (track.stats?.duration != null) { params.duration = String(Math.round(track.stats.duration / 1000)); }
return this.#authenticatedCall("track.updateNowPlaying", params); }
/** * @param {Track} track * @param {number} startedAt Unix timestamp in milliseconds * @param {{ duration?: number }} [_options] duration in milliseconds */ async scrobble(track, startedAt, _options) { const tags = track.tags ?? {}; /** @type {Record<string, string>} */ const params = { timestamp: String(Math.floor(startedAt / 1000)), };
if (tags.title) params.track = tags.title; if (tags.artist) params.artist = tags.artist; if (tags.album) params.album = tags.album; if (tags.albumartist) params.albumArtist = tags.albumartist; if (tags.track?.no != null) params.trackNumber = String(tags.track.no); if (track.stats?.duration != null) { params.duration = String(Math.round(track.stats.duration / 1000)); }
return this.#authenticatedCall("track.scrobble", params); }
// API
/** * Sign a set of API parameters (excluding `format` and `callback`). * * @param {Record<string, string>} params * @returns {string} MD5 hex digest */ #sign(params) { const str = Object.keys(params) .sort() .map((k) => k + params[k]) .join(""); return bytesToHex(md5(utf8ToBytes(str + this.#apiSecret))); }
/** * @param {string} method * @param {Record<string, string>} [params] * @returns {Promise<any>} */ async #call(method, params = {}) { const allParams = { ...params, api_key: this.#apiKey, method }; const api_sig = this.#sign(allParams); const body = new URLSearchParams({ ...allParams, api_sig, format: "json" });
const response = await fetch(LASTFM_API_URL, { method: "POST", body }); const data = await response.json();
if (data.error) { throw new Error(`last.fm error ${data.error}: ${data.message}`); }
return data; }
/** * @param {string} method * @param {Record<string, string>} [params] * @returns {Promise<any>} */ async #authenticatedCall(method, params = {}) { const sk = this.#sessionKey.value; if (!sk) throw new Error("Not authenticated with last.fm"); return this.#call(method, { ...params, sk }); }
/** * @param {string} token * @returns {Promise<{ key: string, name: string }>} */ async #getSession(token) { const data = await this.#call("auth.getSession", { token }); return data.session; }}
export default LastFmScrobbler;
////////////////////////////////////////////// REGISTER////////////////////////////////////////////
export const CLASS = LastFmScrobbler;export const NAME = "ds-lastfm-scrobbler";
defineElement(NAME, CLASS);