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.

feat: listenbrainz scrobbling

+423 -1
+7 -1
src/_data/facets.json
··· 140 140 "title": "Scrobble", 141 141 "kind": "prelude", 142 142 "category": "Misc", 143 - "desc": "Enable scrobbling, keep track of what you're listening to. Adds support for these scrobblers: Last.fm, Rocksky" 143 + "desc": "Enable scrobbling, keep track of what you're listening to. Adds support for these scrobblers: Last.fm, ListenBrainz, Rocksky" 144 144 }, 145 145 { 146 146 "url": "facets/misc/scrobble/last.fm/index.html", 147 147 "title": "Scrobble / Last.fm", 148 148 "category": "Misc", 149 149 "desc": "Connect to Last.fm to setup the Last.fm scrobbler." 150 + }, 151 + { 152 + "url": "facets/misc/scrobble/listenbrainz/index.html", 153 + "title": "Scrobble / ListenBrainz", 154 + "category": "Misc", 155 + "desc": "Connect to ListenBrainz to setup the ListenBrainz scrobbler." 150 156 }, 151 157 { 152 158 "url": "facets/misc/scrobble/rocksky/index.html",
+226
src/components/supplement/listenbrainz/element.js
··· 1 + import { BroadcastableDiffuseElement } from "~/common/element.js"; 2 + import { computed, signal } from "~/common/signal.js"; 3 + 4 + /** 5 + * @import {Track} from "~/definitions/types.d.ts" 6 + * @import {ScrobbleElement} from "../types.d.ts" 7 + */ 8 + 9 + //////////////////////////////////////////// 10 + // CONSTANTS 11 + //////////////////////////////////////////// 12 + 13 + const API_BASE = "https://api.listenbrainz.org/1/"; 14 + const STORAGE_KEY = "diffuse/supplement/listenbrainz/session"; 15 + 16 + //////////////////////////////////////////// 17 + // ELEMENT 18 + //////////////////////////////////////////// 19 + 20 + /** 21 + * @implements {ScrobbleElement} 22 + */ 23 + class ListenBrainzScrobbler extends BroadcastableDiffuseElement { 24 + static NAME = "diffuse/supplement/listenbrainz"; 25 + 26 + // SIGNALS 27 + 28 + #handle = signal(/** @type {string | null} */ (null)); 29 + #userToken = signal(/** @type {string | null} */ (null)); 30 + #isAuthenticating = signal(false); 31 + 32 + // STATE 33 + 34 + handle = this.#handle.get; 35 + isAuthenticated = computed(() => this.#userToken.value !== null); 36 + isAuthenticating = this.#isAuthenticating.get; 37 + 38 + // LIFECYCLE 39 + 40 + /** @override */ 41 + connectedCallback() { 42 + if (this.hasAttribute("group")) { 43 + const actions = this.broadcast(this.identifier, { 44 + nowPlaying: { strategy: "leaderOnly", fn: this.nowPlaying }, 45 + scrobble: { strategy: "leaderOnly", fn: this.scrobble }, 46 + 47 + setHandle: { strategy: "replicate", fn: this.#handle.set }, 48 + setUserToken: { strategy: "replicate", fn: this.#userToken.set }, 49 + }); 50 + 51 + if (actions) { 52 + this.nowPlaying = actions.nowPlaying; 53 + this.scrobble = actions.scrobble; 54 + 55 + this.#handle.set = actions.setHandle; 56 + this.#userToken.set = actions.setUserToken; 57 + } 58 + } 59 + 60 + super.connectedCallback(); 61 + 62 + this.#tryRestore(); 63 + } 64 + 65 + async #tryRestore() { 66 + await this.whenConnected(); 67 + 68 + const stored = localStorage.getItem(STORAGE_KEY); 69 + 70 + if (stored) { 71 + try { 72 + const { token, username } = JSON.parse(stored); 73 + 74 + if (await this.isLeader()) { 75 + this.#userToken.set(token); 76 + this.#handle.set(username); 77 + } else { 78 + this.#userToken.value = token; 79 + this.#handle.value = username; 80 + } 81 + } catch { 82 + localStorage.removeItem(STORAGE_KEY); 83 + } 84 + } 85 + } 86 + 87 + // AUTH 88 + 89 + /** 90 + * Validate a ListenBrainz user token and store the session. 91 + * 92 + * @param {string} token 93 + */ 94 + async signIn(token) { 95 + this.#isAuthenticating.set(true); 96 + try { 97 + const username = await this.#validateToken(token); 98 + this.#userToken.set(token); 99 + this.#handle.set(username); 100 + localStorage.setItem(STORAGE_KEY, JSON.stringify({ token, username })); 101 + } catch (err) { 102 + console.warn("listenbrainz: failed to authenticate", err); 103 + throw err; 104 + } finally { 105 + this.#isAuthenticating.set(false); 106 + } 107 + } 108 + 109 + /** 110 + * Clear the stored session. 111 + */ 112 + signOut() { 113 + this.#userToken.set(null); 114 + this.#handle.set(null); 115 + localStorage.removeItem(STORAGE_KEY); 116 + } 117 + 118 + // SCROBBLE ACTIONS 119 + 120 + /** 121 + * @param {Track} track 122 + */ 123 + async nowPlaying(track) { 124 + return this.#submit("playing_now", [ 125 + { track_metadata: this.#trackMetadata(track) }, 126 + ]); 127 + } 128 + 129 + /** 130 + * @param {Track} track 131 + * @param {number} startedAt Unix timestamp in milliseconds 132 + */ 133 + async scrobble(track, startedAt) { 134 + return this.#submit("single", [ 135 + { 136 + listened_at: Math.floor(startedAt / 1000), 137 + track_metadata: this.#trackMetadata(track), 138 + }, 139 + ]); 140 + } 141 + 142 + // API 143 + 144 + /** 145 + * @param {Track} track 146 + * @returns {Record<string, unknown>} 147 + */ 148 + #trackMetadata(track) { 149 + const tags = track.tags ?? {}; 150 + /** @type {Record<string, unknown>} */ 151 + const additional_info = { submission_client: "Diffuse" }; 152 + 153 + if (track.stats?.duration != null) { 154 + additional_info.duration_ms = track.stats.duration; 155 + } 156 + if (tags.track?.no != null) { 157 + additional_info.tracknumber = tags.track.no; 158 + } 159 + 160 + /** @type {Record<string, unknown>} */ 161 + const metadata = { additional_info }; 162 + 163 + if (tags.title) metadata.track_name = tags.title; 164 + if (tags.artist) metadata.artist_name = tags.artist; 165 + if (tags.album) metadata.release_name = tags.album; 166 + 167 + return metadata; 168 + } 169 + 170 + /** 171 + * @param {string} token 172 + * @returns {Promise<string>} username 173 + */ 174 + async #validateToken(token) { 175 + const response = await fetch(`${API_BASE}validate-token`, { 176 + headers: { Authorization: `Token ${token}` }, 177 + }); 178 + const data = await response.json(); 179 + 180 + if (!data.valid) { 181 + throw new Error( 182 + `listenbrainz: invalid token — ${data.message ?? "unknown error"}`, 183 + ); 184 + } 185 + 186 + return data.user_name; 187 + } 188 + 189 + /** 190 + * @param {string} listen_type 191 + * @param {unknown[]} payload 192 + * @returns {Promise<any>} 193 + */ 194 + async #submit(listen_type, payload) { 195 + const token = this.#userToken.value; 196 + if (!token) throw new Error("Not authenticated with ListenBrainz"); 197 + 198 + const response = await fetch(`${API_BASE}submit-listens`, { 199 + method: "POST", 200 + headers: { 201 + Authorization: `Token ${token}`, 202 + "Content-Type": "application/json", 203 + }, 204 + body: JSON.stringify({ listen_type, payload }), 205 + }); 206 + 207 + const data = await response.json(); 208 + 209 + if (data.code && data.code !== 200) { 210 + throw new Error(`listenbrainz error ${data.code}: ${data.error}`); 211 + } 212 + 213 + return data; 214 + } 215 + } 216 + 217 + export default ListenBrainzScrobbler; 218 + 219 + //////////////////////////////////////////// 220 + // REGISTER 221 + //////////////////////////////////////////// 222 + 223 + export const CLASS = ListenBrainzScrobbler; 224 + export const NAME = "ds-listenbrainz-scrobbler"; 225 + 226 + customElements.define(NAME, CLASS);
+8
src/facets/misc/scrobble/index.inline.js
··· 28 28 const rocksky = new RockskyScrobbler(); 29 29 rocksky.setAttribute("group", foundation.GROUP); 30 30 configurator.append(rocksky); 31 + 32 + const { default: ListenBrainzScrobbler } = await import( 33 + "~/components/supplement/listenbrainz/element.js" 34 + ); 35 + 36 + const listenBrainz = new ListenBrainzScrobbler(); 37 + listenBrainz.setAttribute("group", foundation.GROUP); 38 + configurator.append(listenBrainz); 31 39 }
+69
src/facets/misc/scrobble/listenbrainz/index.html
··· 1 + <style> 2 + @import "./vendor/@awesome.me/webawesome/styles/webawesome.css" layer(wa); 3 + 4 + @layer base, diffuse, wa; 5 + 6 + #container { 7 + display: flex; 8 + align-items: center; 9 + justify-content: center; 10 + min-height: 100dvh; 11 + margin: 0; 12 + } 13 + 14 + wa-card { 15 + width: min(360px, calc(100vw - 2rem)); 16 + } 17 + 18 + .card-header { 19 + display: flex; 20 + align-items: center; 21 + justify-content: space-between; 22 + } 23 + 24 + .card-body { 25 + display: flex; 26 + flex-direction: column; 27 + gap: var(--wa-space-m); 28 + } 29 + 30 + [hidden] { 31 + display: none !important; 32 + } 33 + 34 + p { 35 + margin: 0; 36 + } 37 + </style> 38 + 39 + <main class="wa-theme-default"> 40 + <wa-card> 41 + <div slot="header" class="card-header"> 42 + <strong>ListenBrainz</strong> 43 + </div> 44 + 45 + <div id="state-connect" class="card-body"> 46 + <p>Enter your ListenBrainz user token to start scrobbling.</p> 47 + <wa-input 48 + id="token-input" 49 + label="User token" 50 + type="password" 51 + help-text="Find your token at listenbrainz.org/settings/" 52 + ></wa-input> 53 + <wa-button id="sign-in-btn" variant="brand" appearance="filled"> 54 + <wa-icon slot="start" library="phosphor/bold" name="plugs"></wa-icon> 55 + Connect 56 + </wa-button> 57 + </div> 58 + 59 + <div id="state-connected" class="card-body" hidden> 60 + <p id="handle-paragraph" hidden>Connected as <strong id="handle-text"></strong>.</p> 61 + <wa-button id="sign-out-btn" variant="neutral" appearance="outlined" hidden> 62 + <wa-icon slot="start" library="phosphor/bold" name="plugs-connected"></wa-icon> 63 + Disconnect 64 + </wa-button> 65 + </div> 66 + </wa-card> 67 + </main> 68 + 69 + <script type="module" src="facets/misc/scrobble/listenbrainz/index.inline.js"></script>
+113
src/facets/misc/scrobble/listenbrainz/index.inline.js
··· 1 + import "@awesome.me/webawesome/dist/components/card/card.js"; 2 + import "@awesome.me/webawesome/dist/components/button/button.js"; 3 + import "@awesome.me/webawesome/dist/components/input/input.js"; 4 + import "@awesome.me/webawesome/dist/components/icon/icon.js"; 5 + 6 + import "~/common/webawesome/detect-dark.js"; 7 + import "~/common/webawesome/phosphor/bold.js"; 8 + 9 + import foundation from "~/common/foundation.js"; 10 + import { effect } from "~/common/signal.js"; 11 + 12 + /** 13 + * @import { default as WaInput } from "@awesome.me/webawesome/dist/components/input/input.js" 14 + */ 15 + 16 + //////////////////////////////////////////// 17 + // SETUP 18 + //////////////////////////////////////////// 19 + 20 + foundation.setup({ title: "ListenBrainz | Scrobble | Diffuse" }); 21 + 22 + const configurator = await foundation.configurator.scrobbles(); 23 + 24 + /** @type {import("~/components/supplement/listenbrainz/element.js").CLASS | null} */ 25 + let listenBrainz = configurator.querySelector("ds-listenbrainz-scrobbler"); 26 + if (!listenBrainz) { 27 + const { default: ListenBrainzScrobbler } = await import( 28 + "~/components/supplement/listenbrainz/element.js" 29 + ); 30 + 31 + listenBrainz = new ListenBrainzScrobbler(); 32 + listenBrainz.setAttribute("group", foundation.GROUP); 33 + configurator.append(listenBrainz); 34 + } 35 + 36 + await customElements.whenDefined(listenBrainz.localName); 37 + 38 + //////////////////////////////////////////// 39 + // ELEMENTS 40 + //////////////////////////////////////////// 41 + 42 + const stateConnect = /** @type {HTMLElement} */ ( 43 + document.querySelector("#state-connect") 44 + ); 45 + 46 + const stateConnected = /** @type {HTMLElement} */ ( 47 + document.querySelector("#state-connected") 48 + ); 49 + 50 + const handleParagraph = /** @type {HTMLElement} */ ( 51 + document.querySelector("#handle-paragraph") 52 + ); 53 + 54 + const handleText = /** @type {HTMLElement} */ ( 55 + document.querySelector("#handle-text") 56 + ); 57 + 58 + const tokenInput = /** @type {WaInput} */ ( 59 + document.querySelector("#token-input") 60 + ); 61 + 62 + const signInBtn = /** @type {HTMLElement} */ ( 63 + document.querySelector("#sign-in-btn") 64 + ); 65 + 66 + const signOutBtn = /** @type {HTMLElement} */ ( 67 + document.querySelector("#sign-out-btn") 68 + ); 69 + 70 + //////////////////////////////////////////// 71 + // REACTIVE UI 72 + //////////////////////////////////////////// 73 + 74 + effect(() => { 75 + const isAuthenticated = listenBrainz.isAuthenticated(); 76 + const isAuthenticating = listenBrainz.isAuthenticating(); 77 + const handle = listenBrainz.handle(); 78 + 79 + stateConnect.hidden = isAuthenticated; 80 + stateConnected.hidden = !isAuthenticated; 81 + 82 + handleParagraph.hidden = !handle; 83 + signOutBtn.hidden = !isAuthenticated; 84 + if (handle) handleText.textContent = handle; 85 + 86 + // @ts-ignore 87 + signInBtn.disabled = isAuthenticating; 88 + // @ts-ignore 89 + tokenInput.disabled = isAuthenticating; 90 + }); 91 + 92 + //////////////////////////////////////////// 93 + // ACTIONS 94 + //////////////////////////////////////////// 95 + 96 + signInBtn.onclick = async () => { 97 + const token = tokenInput.value?.trim(); 98 + if (!token) return; 99 + try { 100 + await listenBrainz.signIn(token); 101 + tokenInput.value = ""; 102 + } catch { 103 + // element already logs the error 104 + } 105 + }; 106 + 107 + signOutBtn.onclick = () => listenBrainz.signOut(); 108 + 109 + //////////////////////////////////////////// 110 + // 🚀 111 + //////////////////////////////////////////// 112 + 113 + foundation.ready();