A music player that connects to your cloud/distributed storage.
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

at v4 277 lines 7.8 kB view raw
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);