diff --git a/src/_data/facets.json b/src/_data/facets.json index 2a30240d..fc6bfe0d 100644 --- a/src/_data/facets.json +++ b/src/_data/facets.json @@ -140,7 +140,7 @@ "title": "Scrobble", "kind": "prelude", "category": "Misc", - "desc": "Enable scrobbling, keep track of what you're listening to. Adds support for these scrobblers: Last.fm, Rocksky" + "desc": "Enable scrobbling, keep track of what you're listening to. Adds support for these scrobblers: Last.fm, ListenBrainz, Rocksky" }, { "url": "facets/misc/scrobble/last.fm/index.html", @@ -148,6 +148,12 @@ "category": "Misc", "desc": "Connect to Last.fm to setup the Last.fm scrobbler." }, + { + "url": "facets/misc/scrobble/listenbrainz/index.html", + "title": "Scrobble / ListenBrainz", + "category": "Misc", + "desc": "Connect to ListenBrainz to setup the ListenBrainz scrobbler." + }, { "url": "facets/misc/scrobble/rocksky/index.html", "title": "Scrobble / Rocksky (work in progress)", diff --git a/src/components/supplement/listenbrainz/element.js b/src/components/supplement/listenbrainz/element.js new file mode 100644 index 00000000..597b6fe4 --- /dev/null +++ b/src/components/supplement/listenbrainz/element.js @@ -0,0 +1,226 @@ +import { BroadcastableDiffuseElement } from "~/common/element.js"; +import { computed, signal } from "~/common/signal.js"; + +/** + * @import {Track} from "~/definitions/types.d.ts" + * @import {ScrobbleElement} from "../types.d.ts" + */ + +//////////////////////////////////////////// +// CONSTANTS +//////////////////////////////////////////// + +const API_BASE = "https://api.listenbrainz.org/1/"; +const STORAGE_KEY = "diffuse/supplement/listenbrainz/session"; + +//////////////////////////////////////////// +// ELEMENT +//////////////////////////////////////////// + +/** + * @implements {ScrobbleElement} + */ +class ListenBrainzScrobbler extends BroadcastableDiffuseElement { + static NAME = "diffuse/supplement/listenbrainz"; + + // SIGNALS + + #handle = signal(/** @type {string | null} */ (null)); + #userToken = signal(/** @type {string | null} */ (null)); + #isAuthenticating = signal(false); + + // STATE + + handle = this.#handle.get; + isAuthenticated = computed(() => this.#userToken.value !== null); + isAuthenticating = this.#isAuthenticating.get; + + // LIFECYCLE + + /** @override */ + connectedCallback() { + if (this.hasAttribute("group")) { + const actions = this.broadcast(this.identifier, { + nowPlaying: { strategy: "leaderOnly", fn: this.nowPlaying }, + scrobble: { strategy: "leaderOnly", fn: this.scrobble }, + + setHandle: { strategy: "replicate", fn: this.#handle.set }, + setUserToken: { strategy: "replicate", fn: this.#userToken.set }, + }); + + if (actions) { + this.nowPlaying = actions.nowPlaying; + this.scrobble = actions.scrobble; + + this.#handle.set = actions.setHandle; + this.#userToken.set = actions.setUserToken; + } + } + + super.connectedCallback(); + + this.#tryRestore(); + } + + async #tryRestore() { + await this.whenConnected(); + + const stored = localStorage.getItem(STORAGE_KEY); + + if (stored) { + try { + const { token, username } = JSON.parse(stored); + + if (await this.isLeader()) { + this.#userToken.set(token); + this.#handle.set(username); + } else { + this.#userToken.value = token; + this.#handle.value = username; + } + } catch { + localStorage.removeItem(STORAGE_KEY); + } + } + } + + // AUTH + + /** + * Validate a ListenBrainz user token and store the session. + * + * @param {string} token + */ + async signIn(token) { + this.#isAuthenticating.set(true); + try { + const username = await this.#validateToken(token); + this.#userToken.set(token); + this.#handle.set(username); + localStorage.setItem(STORAGE_KEY, JSON.stringify({ token, username })); + } catch (err) { + console.warn("listenbrainz: failed to authenticate", err); + throw err; + } finally { + this.#isAuthenticating.set(false); + } + } + + /** + * Clear the stored session. + */ + signOut() { + this.#userToken.set(null); + this.#handle.set(null); + localStorage.removeItem(STORAGE_KEY); + } + + // SCROBBLE ACTIONS + + /** + * @param {Track} track + */ + async nowPlaying(track) { + return this.#submit("playing_now", [ + { track_metadata: this.#trackMetadata(track) }, + ]); + } + + /** + * @param {Track} track + * @param {number} startedAt Unix timestamp in milliseconds + */ + async scrobble(track, startedAt) { + return this.#submit("single", [ + { + listened_at: Math.floor(startedAt / 1000), + track_metadata: this.#trackMetadata(track), + }, + ]); + } + + // API + + /** + * @param {Track} track + * @returns {Record} + */ + #trackMetadata(track) { + const tags = track.tags ?? {}; + /** @type {Record} */ + const additional_info = { submission_client: "Diffuse" }; + + if (track.stats?.duration != null) { + additional_info.duration_ms = track.stats.duration; + } + if (tags.track?.no != null) { + additional_info.tracknumber = tags.track.no; + } + + /** @type {Record} */ + const metadata = { additional_info }; + + if (tags.title) metadata.track_name = tags.title; + if (tags.artist) metadata.artist_name = tags.artist; + if (tags.album) metadata.release_name = tags.album; + + return metadata; + } + + /** + * @param {string} token + * @returns {Promise} username + */ + async #validateToken(token) { + const response = await fetch(`${API_BASE}validate-token`, { + headers: { Authorization: `Token ${token}` }, + }); + const data = await response.json(); + + if (!data.valid) { + throw new Error( + `listenbrainz: invalid token — ${data.message ?? "unknown error"}`, + ); + } + + return data.user_name; + } + + /** + * @param {string} listen_type + * @param {unknown[]} payload + * @returns {Promise} + */ + async #submit(listen_type, payload) { + const token = this.#userToken.value; + if (!token) throw new Error("Not authenticated with ListenBrainz"); + + const response = await fetch(`${API_BASE}submit-listens`, { + method: "POST", + headers: { + Authorization: `Token ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ listen_type, payload }), + }); + + const data = await response.json(); + + if (data.code && data.code !== 200) { + throw new Error(`listenbrainz error ${data.code}: ${data.error}`); + } + + return data; + } +} + +export default ListenBrainzScrobbler; + +//////////////////////////////////////////// +// REGISTER +//////////////////////////////////////////// + +export const CLASS = ListenBrainzScrobbler; +export const NAME = "ds-listenbrainz-scrobbler"; + +customElements.define(NAME, CLASS); diff --git a/src/facets/misc/scrobble/index.inline.js b/src/facets/misc/scrobble/index.inline.js index f125f3b8..5c2cc91c 100644 --- a/src/facets/misc/scrobble/index.inline.js +++ b/src/facets/misc/scrobble/index.inline.js @@ -28,4 +28,12 @@ async function setup() { const rocksky = new RockskyScrobbler(); rocksky.setAttribute("group", foundation.GROUP); configurator.append(rocksky); + + const { default: ListenBrainzScrobbler } = await import( + "~/components/supplement/listenbrainz/element.js" + ); + + const listenBrainz = new ListenBrainzScrobbler(); + listenBrainz.setAttribute("group", foundation.GROUP); + configurator.append(listenBrainz); } diff --git a/src/facets/misc/scrobble/listenbrainz/index.html b/src/facets/misc/scrobble/listenbrainz/index.html new file mode 100644 index 00000000..29cd9f7b --- /dev/null +++ b/src/facets/misc/scrobble/listenbrainz/index.html @@ -0,0 +1,69 @@ + + +
+ +
+ ListenBrainz +
+ +
+

Enter your ListenBrainz user token to start scrobbling.

+ + + + Connect + +
+ + +
+
+ + diff --git a/src/facets/misc/scrobble/listenbrainz/index.inline.js b/src/facets/misc/scrobble/listenbrainz/index.inline.js new file mode 100644 index 00000000..fed08ef6 --- /dev/null +++ b/src/facets/misc/scrobble/listenbrainz/index.inline.js @@ -0,0 +1,113 @@ +import "@awesome.me/webawesome/dist/components/card/card.js"; +import "@awesome.me/webawesome/dist/components/button/button.js"; +import "@awesome.me/webawesome/dist/components/input/input.js"; +import "@awesome.me/webawesome/dist/components/icon/icon.js"; + +import "~/common/webawesome/detect-dark.js"; +import "~/common/webawesome/phosphor/bold.js"; + +import foundation from "~/common/foundation.js"; +import { effect } from "~/common/signal.js"; + +/** + * @import { default as WaInput } from "@awesome.me/webawesome/dist/components/input/input.js" + */ + +//////////////////////////////////////////// +// SETUP +//////////////////////////////////////////// + +foundation.setup({ title: "ListenBrainz | Scrobble | Diffuse" }); + +const configurator = await foundation.configurator.scrobbles(); + +/** @type {import("~/components/supplement/listenbrainz/element.js").CLASS | null} */ +let listenBrainz = configurator.querySelector("ds-listenbrainz-scrobbler"); +if (!listenBrainz) { + const { default: ListenBrainzScrobbler } = await import( + "~/components/supplement/listenbrainz/element.js" + ); + + listenBrainz = new ListenBrainzScrobbler(); + listenBrainz.setAttribute("group", foundation.GROUP); + configurator.append(listenBrainz); +} + +await customElements.whenDefined(listenBrainz.localName); + +//////////////////////////////////////////// +// ELEMENTS +//////////////////////////////////////////// + +const stateConnect = /** @type {HTMLElement} */ ( + document.querySelector("#state-connect") +); + +const stateConnected = /** @type {HTMLElement} */ ( + document.querySelector("#state-connected") +); + +const handleParagraph = /** @type {HTMLElement} */ ( + document.querySelector("#handle-paragraph") +); + +const handleText = /** @type {HTMLElement} */ ( + document.querySelector("#handle-text") +); + +const tokenInput = /** @type {WaInput} */ ( + document.querySelector("#token-input") +); + +const signInBtn = /** @type {HTMLElement} */ ( + document.querySelector("#sign-in-btn") +); + +const signOutBtn = /** @type {HTMLElement} */ ( + document.querySelector("#sign-out-btn") +); + +//////////////////////////////////////////// +// REACTIVE UI +//////////////////////////////////////////// + +effect(() => { + const isAuthenticated = listenBrainz.isAuthenticated(); + const isAuthenticating = listenBrainz.isAuthenticating(); + const handle = listenBrainz.handle(); + + stateConnect.hidden = isAuthenticated; + stateConnected.hidden = !isAuthenticated; + + handleParagraph.hidden = !handle; + signOutBtn.hidden = !isAuthenticated; + if (handle) handleText.textContent = handle; + + // @ts-ignore + signInBtn.disabled = isAuthenticating; + // @ts-ignore + tokenInput.disabled = isAuthenticating; +}); + +//////////////////////////////////////////// +// ACTIONS +//////////////////////////////////////////// + +signInBtn.onclick = async () => { + const token = tokenInput.value?.trim(); + if (!token) return; + try { + await listenBrainz.signIn(token); + tokenInput.value = ""; + } catch { + // element already logs the error + } +}; + +signOutBtn.onclick = () => listenBrainz.signOut(); + +//////////////////////////////////////////// +// 🚀 +//////////////////////////////////////////// + +foundation.ready();