A music player that connects to your cloud/distributed storage. diffuse.sh
Something went wrong. Try again.
6.1 kB · 231 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232import { now as tidNow } from "@atcute/tid";
import { BroadcastableDiffuseElement, defineElement } from "~/common/element.js";import { signal } from "~/common/signal.js";import { clearSession, readSession, saveSession } from "../session.js";
import { clearStoredSession, DID_STORAGE_KEY, getSession, login, logout, OAuthUserAgent, restoreOrFinalize,} from "./oauth.js";
/** * @import {Track} from "~/definitions/types.d.ts" * @import {ScrobbleElement} from "@specs/components/supplement/types.d.ts" */
////////////////////////////////////////////// CONSTANTS////////////////////////////////////////////
const STORAGE_KEY = "diffuse/supplement/rocksky/session";
////////////////////////////////////////////// ELEMENT////////////////////////////////////////////
/** * @implements {ScrobbleElement} */class RockskyScrobbler extends BroadcastableDiffuseElement { static NAME = "diffuse/supplement/rocksky";
// SIGNALS
#handle = signal(/** @type {string | null} */ (null)); #connected = signal(false); #isAuthenticating = signal(false);
// STATE
handle = this.#handle.get; isAuthenticated = this.#connected.get; 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 }, setConnected: { strategy: "replicate", fn: this.#connected.set }, });
if (actions) { this.nowPlaying = actions.nowPlaying; this.scrobble = actions.scrobble;
this.#handle.set = actions.setHandle; this.#connected.set = actions.setConnected; } }
super.connectedCallback();
this.#tryRestore(); }
async #tryRestore() { await this.whenConnected();
try { const session = await restoreOrFinalize();
if (session) { const did = session.info.sub;
if (await this.isLeader()) { this.#connected.set(true); this.#handle.set(did); } else { this.#connected.value = true; this.#handle.value = did; }
await saveSession(this, STORAGE_KEY, JSON.stringify({ did })); return; } } catch (err) { console.warn("Rocksky: Failed to restore/finalize session", err); }
// Restore previously stored connection state. const stored = await readSession(this, STORAGE_KEY);
if (stored) { try { const { did } = JSON.parse(stored);
if (await this.isLeader()) { this.#connected.set(true); this.#handle.set(did); } else { this.#connected.value = true; this.#handle.value = did; } } catch { await clearSession(this, STORAGE_KEY); } } }
// AUTH
/** * Connect to Rocksky by initiating the AT Protocol OAuth flow for the given handle. * Navigates the browser away to the authorization server. * * @param {string} handle */ async signIn(handle) { this.#isAuthenticating.set(true);
try { await login(handle); } finally { this.#isAuthenticating.set(false); } }
/** * Disconnect from Rocksky. */ async signOut() { const did = localStorage.getItem(DID_STORAGE_KEY);
if (did) { getSession(/** @type {`did:${string}:${string}`} */ (did)) .then((session) => logout(new OAuthUserAgent(session))) .catch(() => clearStoredSession()); } else { clearStoredSession(); }
this.#connected.set(false); this.#handle.set(null); await clearSession(this, STORAGE_KEY); }
// SCROBBLE ACTIONS
/** * @param {Track} _track */ // deno-lint-ignore no-unused-vars async nowPlaying(_track) { // Rocksky has no now-playing PDS record type; scrobbles are the source of truth. }
/** * @param {Track} track * @param {number} startedAt Unix timestamp in milliseconds * @param {{ duration?: number }} [options] duration in milliseconds */ async scrobble(track, startedAt, { duration: durationMs } = {}) { if (!this.#connected.value) return;
const did = localStorage.getItem(DID_STORAGE_KEY); if (!did) return;
const session = await getSession(/** @type {`did:${string}:${string}`} */ (did)); const agent = new OAuthUserAgent(session);
const tags = track.tags ?? {}; const duration = track.stats?.duration ?? durationMs;
// duration has no meaningful fallback value; skip rather than create an invalid record if (duration == null) return;
/** @type {Record<string, unknown>} */ const record = { $type: "app.rocksky.scrobble", createdAt: new Date(startedAt).toISOString(), title: tags.title || "Unknown", artist: tags.artist || "Unknown", album: tags.album || "Unknown", albumArtist: tags.albumartist || tags.artist || "Unknown", duration, };
if (tags.track?.no != null) record.trackNumber = tags.track.no; if (tags.disc?.no != null) record.discNumber = tags.disc.no;
const response = await agent.handle("/xrpc/com.atproto.repo.putRecord", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ repo: did, collection: "app.rocksky.scrobble", rkey: tidNow(), record, validate: false, }), });
if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(`rocksky: scrobble failed ${response.status}: ${error.message ?? ""}`); } }}
export default RockskyScrobbler;
////////////////////////////////////////////// REGISTER////////////////////////////////////////////
export const CLASS = RockskyScrobbler;export const NAME = "ds-rocksky-scrobbler";
defineElement(NAME, CLASS);