A music player that connects to your cloud/distributed storage.
at v4 273 lines 7.8 kB view raw
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);