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

Configure Feed

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

feat: last.fm scrobbling

+88 -57
+1 -1
src/_data/facets.yaml
··· 12 12 Automatically put tracks into the queue. 13 13 - url: "facets/scrobble/last.fm/index.html" 14 14 title: "Scrobble / Last.fm" 15 - category: Data 15 + category: Misc 16 16 desc: > 17 17 Enable Last.fm scrobbling. 18 18 - url: "facets/tools/export-import/index.html"
+20 -15
src/common/facets/foundation.js
··· 15 15 import MediaSessionOrchestrator from "~/components/orchestrator/media-session/element.js"; 16 16 import ScrobbleAudioOrchestrator from "~/components/orchestrator/scrobble-audio/element.js"; 17 17 import SourcesOrchestrator from "~/components/orchestrator/sources/element.js"; 18 - import ScrobbleConfigurator from "~/components/configurator/scrobbles/element.js"; 18 + import ScrobblesConfigurator from "~/components/configurator/scrobbles/element.js"; 19 + import LastFmScrobbler from "../../components/supplement/last.fm/element.js"; 19 20 20 21 /** 21 22 * @import { DiffuseElement } from "@toko/diffuse/common/element.js"; ··· 31 32 GROUP, 32 33 33 34 features: { 34 - audioScrobbling, 35 35 fillQueueAutomatically, 36 36 playAudioFromQueue, 37 37 processInputs, ··· 71 71 72 72 // 📦️ 73 73 74 - function audioScrobbling() { 75 - return { 76 - configurator: { 77 - scrobbles: scrobbles(), 78 - }, 79 - orchestrator: { 80 - scrobbleAudio: scrobbleAudio(), 81 - }, 82 - }; 83 - } 84 - 85 74 function fillQueueAutomatically() { 86 75 return { 87 76 engine: { ··· 100 89 101 90 function playAudioFromQueue() { 102 91 return { 92 + configurator: { 93 + scrobbles: scrobbles(), 94 + }, 103 95 engine: { 104 96 audio: audio(), 105 97 queue: queue(), ··· 107 99 orchestrator: { 108 100 mediaSession: mediaSession(), 109 101 queueAudio: queueAudio(), 102 + scrobbleAudio: scrobbleAudio(), 110 103 }, 111 104 }; 112 105 } ··· 143 136 144 137 // Configurators 145 138 function scrobbles() { 146 - const sc = new ScrobbleConfigurator(); 139 + const sc = new ScrobblesConfigurator(); 147 140 sc.setAttribute("group", GROUP); 148 141 sc.setAttribute("id", "scrobbles"); 149 142 150 - return findExistingOrAdd(sc); 143 + const existing = document.body.querySelector(sc.selector); 144 + 145 + if (existing) { 146 + return /** @type {ScrobblesConfigurator} */ (existing); 147 + } 148 + 149 + const lastFm = new LastFmScrobbler(); 150 + lastFm.setAttribute("group", GROUP); 151 + 152 + sc.append(lastFm); 153 + 154 + document.body.append(sc); 155 + return sc; 151 156 } 152 157 153 158 // Engines
+4 -4
src/components/configurator/scrobbles/element.js
··· 12 12 /** 13 13 * @implements {ScrobbleActions} 14 14 */ 15 - class ScrobbleConfigurator extends DiffuseElement { 15 + class ScrobblesConfigurator extends DiffuseElement { 16 16 static NAME = "diffuse/configurator/scrobbles"; 17 17 18 18 // SCROBBLE ACTIONS ··· 60 60 } 61 61 } 62 62 63 - export default ScrobbleConfigurator; 63 + export default ScrobblesConfigurator; 64 64 65 65 //////////////////////////////////////////// 66 66 // REGISTER 67 67 //////////////////////////////////////////// 68 68 69 - export const CLASS = ScrobbleConfigurator; 69 + export const CLASS = ScrobblesConfigurator; 70 70 export const NAME = "dc-scrobbles"; 71 71 72 - customElements.define(NAME, ScrobbleConfigurator); 72 + customElements.define(NAME, CLASS);
+55 -33
src/components/supplement/last.fm/element.js
··· 1 1 import { md5 } from "@noble/hashes/legacy.js"; 2 2 import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js"; 3 3 4 - import { DiffuseElement } from "~/common/element.js"; 4 + import { BroadcastableDiffuseElement } from "~/common/element.js"; 5 5 import { computed, signal } from "~/common/signal.js"; 6 6 7 7 /** ··· 16 16 const LASTFM_API_URL = "https://ws.audioscrobbler.com/2.0/"; 17 17 const LASTFM_AUTH_URL = "https://www.last.fm/api/auth/"; 18 18 const STORAGE_KEY = "diffuse/supplement/last.fm/session"; 19 - const PENDING_TOKEN_KEY = "diffuse/supplement/last.fm/pending-token"; 20 19 21 20 const DEFAULT_API_KEY = "4f0fe85b67baef8bb7d008a8754a95e5"; 22 21 const DEFAULT_API_SECRET = "0cec3ca0f58e04a5082f1131aba1e0d3"; ··· 28 27 /** 29 28 * @implements {ScrobbleElement} 30 29 */ 31 - class LastFmSupplement extends DiffuseElement { 30 + class LastFmScrobbler extends BroadcastableDiffuseElement { 32 31 static NAME = "diffuse/supplement/last.fm"; 33 32 34 33 get #apiKey() { ··· 41 40 42 41 // SIGNALS 43 42 43 + #handle = signal(/** @type {string | null} */ (null)); 44 44 #sessionKey = signal(/** @type {string | null} */ (null)); 45 - #handle = signal(/** @type {string | null} */ (null)); 46 45 47 46 // STATE 48 47 49 - isAuthenticated = computed(() => this.#sessionKey.value !== null); 50 48 handle = this.#handle.get; 49 + isAuthenticated = computed(() => this.#sessionKey.value !== null); 51 50 52 51 // LIFECYCLE 53 52 54 53 /** @override */ 55 54 connectedCallback() { 55 + // Broadcast if needed 56 + if (this.hasAttribute("group")) { 57 + const actions = this.broadcast(this.identifier, { 58 + nowPlaying: { strategy: "leaderOnly", fn: this.nowPlaying }, 59 + scrobble: { strategy: "leaderOnly", fn: this.scrobble }, 60 + 61 + setHandle: { strategy: "replicate", fn: this.#handle.set }, 62 + setSession: { strategy: "replicate", fn: this.#sessionKey.set }, 63 + }); 64 + 65 + if (actions) { 66 + this.nowPlaying = actions.nowPlaying; 67 + this.scrobble = actions.scrobble; 68 + 69 + this.#handle.set = actions.setHandle; 70 + this.#sessionKey.set = actions.setSession; 71 + } 72 + } 73 + 56 74 super.connectedCallback(); 75 + 57 76 this.#tryRestore(); 58 77 } 59 78 60 79 async #tryRestore() { 61 80 await this.whenConnected(); 62 81 63 - // Check for a pending token in sessionStorage (returning from auth redirect) 64 - const pendingToken = sessionStorage.getItem(PENDING_TOKEN_KEY); 82 + // last.fm appends ?token=TOKEN to the callback URL after authorization. 83 + const urlParams = new URLSearchParams(location.search); 84 + const urlToken = urlParams.get("token"); 65 85 66 - if (pendingToken) { 67 - sessionStorage.removeItem(PENDING_TOKEN_KEY); 86 + if (urlToken) { 87 + urlParams.delete("token"); 88 + const newSearch = urlParams.toString(); 89 + history.replaceState( 90 + null, 91 + "", 92 + location.pathname + (newSearch ? "?" + newSearch : "") + location.hash, 93 + ); 68 94 69 95 try { 70 - const session = await this.#getSession(pendingToken); 96 + const session = await this.#getSession(urlToken); 71 97 this.#setSession(session); 72 98 } catch (err) { 73 99 console.warn("last.fm: failed to exchange token for session", err); ··· 82 108 if (stored) { 83 109 try { 84 110 const { key, name: handle } = JSON.parse(stored); 85 - this.#sessionKey.value = key; 86 - this.#handle.value = handle; 111 + if (await this.isLeader()) { 112 + this.#sessionKey.set(key); 113 + this.#handle.set(handle); 114 + } else { 115 + this.#sessionKey.value = key; 116 + this.#handle.value = handle; 117 + } 87 118 } catch { 88 119 localStorage.removeItem(STORAGE_KEY); 89 120 } ··· 94 125 95 126 /** 96 127 * Initiate the last.fm auth flow. 97 - * Requests a token and redirects the browser to the authorization page. 128 + * Redirects the browser to the authorization page; last.fm appends ?token=TOKEN to the callback. 98 129 */ 99 - async signIn() { 100 - const token = await this.#getToken(); 101 - 102 - sessionStorage.setItem(PENDING_TOKEN_KEY, token); 103 - 130 + signIn() { 104 131 const callbackUrl = location.origin + location.pathname + location.search; 105 132 const authUrl = new URL(LASTFM_AUTH_URL); 106 133 authUrl.searchParams.set("api_key", this.#apiKey); 107 - authUrl.searchParams.set("token", token); 108 134 authUrl.searchParams.set("cb", callbackUrl); 109 135 110 - location.assign(authUrl.toString()); 136 + // Navigate the top-level frame so last.fm's X-Frame-Options doesn't block loading 137 + // when this element is used inside an iframe. 138 + (window.top ?? window).location.assign(authUrl.toString()); 111 139 } 112 140 113 141 /** 114 142 * Clear the stored session. 115 143 */ 116 144 signOut() { 117 - this.#sessionKey.value = null; 118 - this.#handle.value = null; 145 + this.#sessionKey.set(null); 146 + this.#handle.set(null); 119 147 localStorage.removeItem(STORAGE_KEY); 120 148 } 121 149 122 150 /** @param {{ key: string, name: string }} session */ 123 151 #setSession({ key, name: handle }) { 124 - this.#sessionKey.value = key; 125 - this.#handle.value = handle; 152 + this.#sessionKey.set(key); 153 + this.#handle.set(handle); 126 154 localStorage.setItem(STORAGE_KEY, JSON.stringify({ key, name: handle })); 127 155 } 128 156 ··· 218 246 return this.#call(method, { ...params, sk }); 219 247 } 220 248 221 - /** @returns {Promise<string>} */ 222 - async #getToken() { 223 - const data = await this.#call("auth.getToken"); 224 - return data.token; 225 - } 226 - 227 249 /** 228 250 * @param {string} token 229 251 * @returns {Promise<{ key: string, name: string }>} ··· 234 256 } 235 257 } 236 258 237 - export default LastFmSupplement; 259 + export default LastFmScrobbler; 238 260 239 261 //////////////////////////////////////////// 240 262 // REGISTER 241 263 //////////////////////////////////////////// 242 264 243 - export const CLASS = LastFmSupplement; 244 - export const NAME = "ds-lastfm"; 265 + export const CLASS = LastFmScrobbler; 266 + export const NAME = "ds-lastfm-scrobbler"; 245 267 246 268 customElements.define(NAME, CLASS);
+8 -4
src/facets/scrobble/last.fm/index.inline.js
··· 6 6 7 7 import "~/common/webawesome/detect-dark.js"; 8 8 9 - import LastFmSupplement from "~/components/supplement/last.fm/element.js"; 9 + import LastFmScrobbler from "~/components/supplement/last.fm/element.js"; 10 10 import { effect } from "~/common/signal.js"; 11 + import { GROUP } from "~/common/facets/foundation.js"; 11 12 12 13 /** 13 14 * @import { default as WaDrawer } from "@awesome.me/webawesome/dist/components/drawer/drawer.js" ··· 30 31 } 31 32 32 33 // Find existing or create new ds-lastfm element 33 - let lastFm = /** @type {LastFmSupplement | null} */ ( 34 - document.body.querySelector("ds-lastfm") 34 + let lastFm = /** @type {LastFmScrobbler | null} */ ( 35 + document.body.querySelector("ds-lastfm-scrobbler") 35 36 ); 36 37 37 38 if (!lastFm) { 38 - lastFm = new LastFmSupplement(); 39 + lastFm = new LastFmScrobbler(); 40 + lastFm.setAttribute("group", GROUP) 41 + 39 42 const creds = loadCredentials(); 40 43 if (creds) { 41 44 lastFm.setAttribute("api-key", creds.apiKey); 42 45 lastFm.setAttribute("api-secret", creds.apiSecret); 43 46 } 47 + 44 48 document.body.append(lastFm); 45 49 } 46 50