A music player that connects to your cloud/distributed storage.
1import { md5 } from "@noble/hashes/legacy.js";
2import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
3
4import { BroadcastableDiffuseElement } from "~/common/element.js";
5import { computed, signal } from "~/common/signal.js";
6
7/**
8 * @import {Track} from "~/definitions/types.d.ts"
9 * @import {ScrobbleElement} from "../types.d.ts"
10 */
11
12////////////////////////////////////////////
13// CONSTANTS
14////////////////////////////////////////////
15
16const LASTFM_API_URL = "https://ws.audioscrobbler.com/2.0/";
17const LASTFM_AUTH_URL = "https://www.last.fm/api/auth/";
18const STORAGE_KEY = "diffuse/supplement/last.fm/session";
19
20const DEFAULT_API_KEY = "4f0fe85b67baef8bb7d008a8754a95e5";
21const DEFAULT_API_SECRET = "0cec3ca0f58e04a5082f1131aba1e0d3";
22
23////////////////////////////////////////////
24// ELEMENT
25////////////////////////////////////////////
26
27/**
28 * @implements {ScrobbleElement}
29 */
30class LastFmScrobbler extends BroadcastableDiffuseElement {
31 static NAME = "diffuse/supplement/last.fm";
32
33 get #apiKey() {
34 return this.getAttribute("api-key") ?? DEFAULT_API_KEY;
35 }
36
37 get #apiSecret() {
38 return this.getAttribute("api-secret") ?? DEFAULT_API_SECRET;
39 }
40
41 // SIGNALS
42
43 #handle = signal(/** @type {string | null} */ (null));
44 #sessionKey = signal(/** @type {string | null} */ (null));
45 #isAuthenticating = signal(false);
46
47 // STATE
48
49 handle = this.#handle.get;
50 isAuthenticated = computed(() => this.#sessionKey.value !== null);
51 isAuthenticating = this.#isAuthenticating.get;
52
53 // LIFECYCLE
54
55 /** @override */
56 connectedCallback() {
57 // Broadcast if needed
58 if (this.hasAttribute("group")) {
59 const actions = this.broadcast(this.identifier, {
60 nowPlaying: { strategy: "leaderOnly", fn: this.nowPlaying },
61 scrobble: { strategy: "leaderOnly", fn: this.scrobble },
62
63 setHandle: { strategy: "replicate", fn: this.#handle.set },
64 setSession: { strategy: "replicate", fn: this.#sessionKey.set },
65 });
66
67 if (actions) {
68 this.nowPlaying = actions.nowPlaying;
69 this.scrobble = actions.scrobble;
70
71 this.#handle.set = actions.setHandle;
72 this.#sessionKey.set = actions.setSession;
73 }
74 }
75
76 super.connectedCallback();
77
78 this.#tryRestore();
79 }
80
81 async #tryRestore() {
82 await this.whenConnected();
83
84 // last.fm appends ?token=TOKEN to the callback URL after authorization.
85 const urlParams = new URLSearchParams(location.search);
86 const urlToken = urlParams.get("token");
87
88 if (urlToken) {
89 urlParams.delete("token");
90 const newSearch = urlParams.toString();
91 history.replaceState(
92 null,
93 "",
94 location.pathname + (newSearch ? "?" + newSearch : "") + location.hash,
95 );
96
97 this.#isAuthenticating.set(true);
98 try {
99 const session = await this.#getSession(urlToken);
100 this.#setSession(session);
101 } catch (err) {
102 console.warn("last.fm: failed to exchange token for session", err);
103 } finally {
104 this.#isAuthenticating.set(false);
105 }
106
107 return;
108 }
109
110 // Restore an existing session from localStorage
111 const stored = localStorage.getItem(STORAGE_KEY);
112
113 if (stored) {
114 try {
115 const { key, name: handle } = JSON.parse(stored);
116 if (await this.isLeader()) {
117 this.#sessionKey.set(key);
118 this.#handle.set(handle);
119 } else {
120 this.#sessionKey.value = key;
121 this.#handle.value = handle;
122 }
123 } catch {
124 localStorage.removeItem(STORAGE_KEY);
125 }
126 }
127 }
128
129 // AUTH
130
131 /**
132 * Initiate the last.fm auth flow.
133 * Redirects the browser to the authorization page; last.fm appends ?token=TOKEN to the callback.
134 */
135 signIn() {
136 const callbackUrl = location.origin + location.pathname + location.search;
137 const authUrl = new URL(LASTFM_AUTH_URL);
138 authUrl.searchParams.set("api_key", this.#apiKey);
139 authUrl.searchParams.set("cb", callbackUrl);
140
141 // Navigate the top-level frame so last.fm's X-Frame-Options doesn't block loading
142 // when this element is used inside an iframe.
143 (window.top ?? window).location.assign(authUrl.toString());
144 }
145
146 /**
147 * Clear the stored session.
148 */
149 signOut() {
150 this.#sessionKey.set(null);
151 this.#handle.set(null);
152 localStorage.removeItem(STORAGE_KEY);
153 }
154
155 /** @param {{ key: string, name: string }} session */
156 #setSession({ key, name: handle }) {
157 this.#sessionKey.set(key);
158 this.#handle.set(handle);
159 localStorage.setItem(STORAGE_KEY, JSON.stringify({ key, name: handle }));
160 }
161
162 // SCROBBLE ACTIONS
163
164 /**
165 * @param {Track} track
166 */
167 async nowPlaying(track) {
168 const tags = track.tags ?? {};
169 /** @type {Record<string, string>} */
170 const params = {};
171
172 if (tags.title) params.track = tags.title;
173 if (tags.artist) params.artist = tags.artist;
174 if (tags.album) params.album = tags.album;
175 if (tags.albumartist) params.albumArtist = tags.albumartist;
176 if (tags.track?.no != null) params.trackNumber = String(tags.track.no);
177 if (track.stats?.duration != null) {
178 params.duration = String(Math.round(track.stats.duration / 1000));
179 }
180
181 return this.#authenticatedCall("track.updateNowPlaying", params);
182 }
183
184 /**
185 * @param {Track} track
186 * @param {number} startedAt Unix timestamp in milliseconds
187 */
188 async scrobble(track, startedAt) {
189 const tags = track.tags ?? {};
190 /** @type {Record<string, string>} */
191 const params = {
192 timestamp: String(Math.floor(startedAt / 1000)),
193 };
194
195 if (tags.title) params.track = tags.title;
196 if (tags.artist) params.artist = tags.artist;
197 if (tags.album) params.album = tags.album;
198 if (tags.albumartist) params.albumArtist = tags.albumartist;
199 if (tags.track?.no != null) params.trackNumber = String(tags.track.no);
200 if (track.stats?.duration != null) {
201 params.duration = String(Math.round(track.stats.duration / 1000));
202 }
203
204 return this.#authenticatedCall("track.scrobble", params);
205 }
206
207 // API
208
209 /**
210 * Sign a set of API parameters (excluding `format` and `callback`).
211 *
212 * @param {Record<string, string>} params
213 * @returns {string} MD5 hex digest
214 */
215 #sign(params) {
216 const str = Object.keys(params)
217 .sort()
218 .map((k) => k + params[k])
219 .join("");
220 return bytesToHex(md5(utf8ToBytes(str + this.#apiSecret)));
221 }
222
223 /**
224 * @param {string} method
225 * @param {Record<string, string>} [params]
226 * @returns {Promise<any>}
227 */
228 async #call(method, params = {}) {
229 const allParams = { ...params, api_key: this.#apiKey, method };
230 const api_sig = this.#sign(allParams);
231 const body = new URLSearchParams({ ...allParams, api_sig, format: "json" });
232
233 const response = await fetch(LASTFM_API_URL, { method: "POST", body });
234 const data = await response.json();
235
236 if (data.error) {
237 throw new Error(`last.fm error ${data.error}: ${data.message}`);
238 }
239
240 return data;
241 }
242
243 /**
244 * @param {string} method
245 * @param {Record<string, string>} [params]
246 * @returns {Promise<any>}
247 */
248 async #authenticatedCall(method, params = {}) {
249 const sk = this.#sessionKey.value;
250 if (!sk) throw new Error("Not authenticated with last.fm");
251 return this.#call(method, { ...params, sk });
252 }
253
254 /**
255 * @param {string} token
256 * @returns {Promise<{ key: string, name: string }>}
257 */
258 async #getSession(token) {
259 const data = await this.#call("auth.getSession", { token });
260 return data.session;
261 }
262}
263
264export default LastFmScrobbler;
265
266////////////////////////////////////////////
267// REGISTER
268////////////////////////////////////////////
269
270export const CLASS = LastFmScrobbler;
271export const NAME = "ds-lastfm-scrobbler";
272
273customElements.define(NAME, CLASS);