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