diff --git a/src/_data/facets.json b/src/_data/facets.json index f26e126a..2a30240d 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" + "desc": "Enable scrobbling, keep track of what you're listening to. Adds support for these scrobblers: Last.fm, 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/rocksky/index.html", + "title": "Scrobble / Rocksky (work in progress)", + "category": "Misc", + "desc": "Connect to Rocksky to setup the Rocksky scrobbler." + }, { "url": "facets/misc/split-view/index.html", "title": "Split View", diff --git a/src/_includes/layouts/diffuse.vto b/src/_includes/layouts/diffuse.vto index 9717f886..28af3593 100644 --- a/src/_includes/layouts/diffuse.vto +++ b/src/_includes/layouts/diffuse.vto @@ -49,6 +49,7 @@ base: "./" "@phosphor-icons/web/": "./vendor/@phosphor-icons/web/", "@atcute/cbor": "./vendor/@atcute/cbor/index.js", + "@atcute/oauth-browser-client": "./vendor/@atcute/oauth-browser-client/index.js", "@atcute/tid": "./vendor/@atcute/tid/index.js", "idb-keyval": "./vendor/idb-keyval/index.js", "kmenu": "./vendor/kmenu-core/index.js", diff --git a/src/components/supplement/rocksky/element.js b/src/components/supplement/rocksky/element.js new file mode 100644 index 00000000..aab39b02 --- /dev/null +++ b/src/components/supplement/rocksky/element.js @@ -0,0 +1,250 @@ +import { md5 } from "@noble/hashes/legacy.js"; +import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js"; + +import { getSession } from "@atcute/oauth-browser-client"; + +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 ROCKSKY_API_URL = "https://audioscrobbler.rocksky.app/2.0/"; +const ATPROTO_DID_KEY = "diffuse/output/raw/atproto/did"; +const STORAGE_KEY = "diffuse/supplement/rocksky/session"; + +const DEFAULT_API_KEY = "d21bdb464bd5e92c4dbbe814a5a9a8a4"; +const DEFAULT_API_SECRET = "4a9d15e43ad1623ee7f9dc6b12d6ba08"; + +//////////////////////////////////////////// +// ELEMENT +//////////////////////////////////////////// + +/** + * @implements {ScrobbleElement} + */ +class RockskyScrobbler extends BroadcastableDiffuseElement { + static NAME = "diffuse/supplement/rocksky"; + + get #apiKey() { + return this.getAttribute("api-key") ?? DEFAULT_API_KEY; + } + + get #apiSecret() { + return this.getAttribute("api-secret") ?? DEFAULT_API_SECRET; + } + + // SIGNALS + + #handle = signal(/** @type {string | null} */ (null)); + #sessionKey = signal(/** @type {string | null} */ (null)); + #isAuthenticating = signal(false); + + // STATE + + handle = this.#handle.get; + isAuthenticated = computed(() => this.#sessionKey.value !== null); + isAuthenticating = this.#isAuthenticating.get; + + // LIFECYCLE + + /** @override */ + connectedCallback() { + // Broadcast if needed + 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 }, + setSession: { strategy: "replicate", fn: this.#sessionKey.set }, + }); + + if (actions) { + this.nowPlaying = actions.nowPlaying; + this.scrobble = actions.scrobble; + + this.#handle.set = actions.setHandle; + this.#sessionKey.set = actions.setSession; + } + } + + super.connectedCallback(); + + this.#tryRestore(); + } + + async #tryRestore() { + await this.whenConnected(); + + const stored = localStorage.getItem(STORAGE_KEY); + + if (stored) { + try { + const { key, name: handle } = JSON.parse(stored); + if (await this.isLeader()) { + this.#sessionKey.set(key); + this.#handle.set(handle); + } else { + this.#sessionKey.value = key; + this.#handle.value = handle; + } + } catch { + localStorage.removeItem(STORAGE_KEY); + } + } + } + + // AUTH + + /** + * Sign in to Rocksky using the existing AT Protocol session. + * Exchanges the AT Protocol access token for a Rocksky audioscrobbler session key. + */ + async signIn() { + const did = localStorage.getItem(ATPROTO_DID_KEY); + if (!did) throw new Error("rocksky: no AT Protocol session found"); + + this.#isAuthenticating.set(true); + try { + const session = await getSession( + /** @type {`did:${string}:${string}`} */ (did), + ); + const accessToken = session.token.access; + + const data = await this.#call("auth.getMobileSession", { + username: did, + password: accessToken, + }); + this.#setSession(data.session); + } catch (err) { + console.warn("rocksky: failed to authenticate", err); + throw err; + } finally { + this.#isAuthenticating.set(false); + } + } + + /** + * Clear the stored session. + */ + signOut() { + this.#sessionKey.set(null); + this.#handle.set(null); + localStorage.removeItem(STORAGE_KEY); + } + + /** @param {{ key: string, name: string }} session */ + #setSession({ key, name: handle }) { + this.#sessionKey.set(key); + this.#handle.set(handle); + localStorage.setItem(STORAGE_KEY, JSON.stringify({ key, name: handle })); + } + + // SCROBBLE ACTIONS + + /** + * @param {Track} track + */ + async nowPlaying(track) { + const tags = track.tags ?? {}; + /** @type {Record} */ + const params = {}; + + if (tags.title) params.track = tags.title; + if (tags.artist) params.artist = tags.artist; + if (tags.album) params.album = tags.album; + if (tags.albumartist) params.albumArtist = tags.albumartist; + if (tags.track?.no != null) params.trackNumber = String(tags.track.no); + if (track.stats?.duration != null) { + params.duration = String(Math.round(track.stats.duration / 1000)); + } + + return this.#authenticatedCall("track.updateNowPlaying", params); + } + + /** + * @param {Track} track + * @param {number} startedAt Unix timestamp in milliseconds + */ + async scrobble(track, startedAt) { + const tags = track.tags ?? {}; + /** @type {Record} */ + const params = { + timestamp: String(Math.floor(startedAt / 1000)), + }; + + if (tags.title) params.track = tags.title; + if (tags.artist) params.artist = tags.artist; + if (tags.album) params.album = tags.album; + if (tags.albumartist) params.albumArtist = tags.albumartist; + if (tags.track?.no != null) params.trackNumber = String(tags.track.no); + if (track.stats?.duration != null) { + params.duration = String(Math.round(track.stats.duration / 1000)); + } + + return this.#authenticatedCall("track.scrobble", params); + } + + // API + + /** + * @param {Record} params + * @returns {string} MD5 hex digest + */ + #sign(params) { + const str = Object.keys(params) + .sort() + .map((k) => k + params[k]) + .join(""); + return bytesToHex(md5(utf8ToBytes(str + this.#apiSecret))); + } + + /** + * @param {string} method + * @param {Record} [params] + * @returns {Promise} + */ + async #call(method, params = {}) { + const allParams = { ...params, api_key: this.#apiKey, method }; + const api_sig = this.#sign(allParams); + const body = new URLSearchParams({ ...allParams, api_sig, format: "json" }); + + const response = await fetch(ROCKSKY_API_URL, { method: "POST", body }); + const data = await response.json(); + + if (data.error) { + throw new Error(`rocksky error ${data.error}: ${data.message}`); + } + + return data; + } + + /** + * @param {string} method + * @param {Record} [params] + * @returns {Promise} + */ + async #authenticatedCall(method, params = {}) { + const sk = this.#sessionKey.value; + if (!sk) throw new Error("Not authenticated with Rocksky"); + return this.#call(method, { ...params, sk }); + } +} + +export default RockskyScrobbler; + +//////////////////////////////////////////// +// REGISTER +//////////////////////////////////////////// + +export const CLASS = RockskyScrobbler; +export const NAME = "ds-rocksky-scrobbler"; + +customElements.define(NAME, CLASS); diff --git a/src/facets/misc/scrobble/index.inline.js b/src/facets/misc/scrobble/index.inline.js index 180406ed..f125f3b8 100644 --- a/src/facets/misc/scrobble/index.inline.js +++ b/src/facets/misc/scrobble/index.inline.js @@ -20,4 +20,12 @@ async function setup() { const lastFm = new LastFmScrobbler(); lastFm.setAttribute("group", foundation.GROUP); configurator.append(lastFm); + + const { default: RockskyScrobbler } = await import( + "~/components/supplement/rocksky/element.js" + ); + + const rocksky = new RockskyScrobbler(); + rocksky.setAttribute("group", foundation.GROUP); + configurator.append(rocksky); } diff --git a/src/facets/misc/scrobble/rocksky/index.html b/src/facets/misc/scrobble/rocksky/index.html new file mode 100644 index 00000000..8991d95b --- /dev/null +++ b/src/facets/misc/scrobble/rocksky/index.html @@ -0,0 +1,74 @@ + + +
+ +
+ Rocksky +
+ +
+
+

Sign in with your AT Protocol identity first, then connect to Rocksky.

+ + + + Sign in with AT Protocol + +
+ + +
+ + +
+
+ + diff --git a/src/facets/misc/scrobble/rocksky/index.inline.js b/src/facets/misc/scrobble/rocksky/index.inline.js new file mode 100644 index 00000000..d9374bb5 --- /dev/null +++ b/src/facets/misc/scrobble/rocksky/index.inline.js @@ -0,0 +1,157 @@ +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 { login } from "~/components/output/raw/atproto/oauth.js"; +import { finalizeAuthorization } from "@atcute/oauth-browser-client"; + +import foundation from "~/common/foundation.js"; +import { effect, signal } from "~/common/signal.js"; + +/** + * @import { default as WaInput } from "@awesome.me/webawesome/dist/components/input/input.js" + */ + +//////////////////////////////////////////// +// SETUP +//////////////////////////////////////////// + +// Set doc title +foundation.setup({ title: "Rocksky | Scrobble | Diffuse" }); + +const ATPROTO_DID_KEY = "diffuse/output/raw/atproto/did"; + +// Handle AT Protocol OAuth callback if returning from it. +// The /oauth/callback page passes the #code fragment back to this page's URL. +// We only finalize if the code is actually present — never attempt session +// restoration, as its error path calls clearStoredSession() which would wipe +// the main app's AT Protocol session from localStorage and IndexedDB. +let freshAtprotoSession = null; +const hashParams = new URLSearchParams(location.hash.slice(1)); +if (hashParams.has("code")) { + try { + const result = await finalizeAuthorization(hashParams); + history.replaceState(null, "", location.pathname + location.search); + localStorage.setItem(ATPROTO_DID_KEY, result.session.info.sub); + freshAtprotoSession = result.session; + } catch (err) { + console.warn("rocksky: failed to finalize AT Protocol auth", err); + } +} + +const configurator = await foundation.configurator.scrobbles(); + +/** @type {import("~/components/supplement/rocksky/element.js").CLASS | null} */ +let rocksky = configurator.querySelector("ds-rocksky-scrobbler"); +if (!rocksky) { + const { default: RockskyScrobbler } = await import( + "~/components/supplement/rocksky/element.js" + ); + + rocksky = new RockskyScrobbler(); + rocksky.setAttribute("group", foundation.GROUP); + configurator.append(rocksky); +} + +await customElements.whenDefined(rocksky.localName); + +// If AT Protocol was just authorized via OAuth, immediately connect to Rocksky +if (freshAtprotoSession && !rocksky.isAuthenticated()) { + rocksky.signIn().catch(() => {}); +} + +//////////////////////////////////////////// +// ELEMENTS +//////////////////////////////////////////// + +const stateConnect = /** @type {HTMLElement} */ ( + document.querySelector("#state-connect") +); + +const stateConnected = /** @type {HTMLElement} */ ( + document.querySelector("#state-connected") +); + +const stateNoAtproto = /** @type {HTMLElement} */ ( + document.querySelector("#state-no-atproto") +); + +const stateHasAtproto = /** @type {HTMLElement} */ ( + document.querySelector("#state-has-atproto") +); + +const handleParagraph = /** @type {HTMLElement} */ ( + document.querySelector("#handle-paragraph") +); + +const handleText = /** @type {HTMLElement} */ ( + document.querySelector("#handle-text") +); + +const handleInput = /** @type {WaInput} */ ( + document.querySelector("#handle-input") +); + +const atprotoSignInBtn = /** @type {HTMLElement} */ ( + document.querySelector("#atproto-sign-in-btn") +); + +const signInBtn = /** @type {HTMLElement} */ ( + document.querySelector("#sign-in-btn") +); + +const signOutBtn = /** @type {HTMLElement} */ ( + document.querySelector("#sign-out-btn") +); + +//////////////////////////////////////////// +// REACTIVE UI +//////////////////////////////////////////// + +const $hasAtprotoSession = signal(!!localStorage.getItem(ATPROTO_DID_KEY)); + +effect(() => { + const isAuthenticated = rocksky.isAuthenticated(); + const isAuthenticating = rocksky.isAuthenticating(); + const handle = rocksky.handle(); + const hasAtproto = $hasAtprotoSession.value; + + stateConnect.hidden = isAuthenticated; + stateConnected.hidden = !isAuthenticated; + + stateNoAtproto.hidden = hasAtproto; + stateHasAtproto.hidden = !hasAtproto; + + handleParagraph.hidden = !handle; + signOutBtn.hidden = !isAuthenticated; + if (handle) handleText.textContent = handle; + + // @ts-ignore + signInBtn.disabled = isAuthenticating; + // @ts-ignore + atprotoSignInBtn.disabled = isAuthenticating; +}); + +//////////////////////////////////////////// +// ACTIONS +//////////////////////////////////////////// + +atprotoSignInBtn.onclick = async () => { + const handle = handleInput.value?.trim(); + if (!handle) return; + await login(handle); +}; + +signInBtn.onclick = () => rocksky.signIn().catch(() => {}); + +signOutBtn.onclick = () => rocksky.signOut(); + +//////////////////////////////////////////// +// 🚀 +//////////////////////////////////////////// + +foundation.ready(); diff --git a/src/vendor/@atcute/oauth-browser-client/index.js b/src/vendor/@atcute/oauth-browser-client/index.js new file mode 100644 index 00000000..427bced0 --- /dev/null +++ b/src/vendor/@atcute/oauth-browser-client/index.js @@ -0,0 +1 @@ +export * from "@atcute/oauth-browser-client";