A music player that connects to your cloud/distributed storage.
1import { now as tidNow } from "@atcute/tid";
2
3import { BroadcastableDiffuseElement, defineElement } from "~/common/element.js";
4import { signal } from "~/common/signal.js";
5import { clearSession, readSession, saveSession } from "../session.js";
6
7import {
8 clearStoredSession,
9 DID_STORAGE_KEY,
10 getSession,
11 login,
12 logout,
13 OAuthUserAgent,
14 restoreOrFinalize,
15} from "./oauth.js";
16
17/**
18 * @import {Track} from "~/definitions/types.d.ts"
19 * @import {ScrobbleElement} from "../types.d.ts"
20 */
21
22////////////////////////////////////////////
23// CONSTANTS
24////////////////////////////////////////////
25
26const STORAGE_KEY = "diffuse/supplement/rocksky/session";
27
28////////////////////////////////////////////
29// ELEMENT
30////////////////////////////////////////////
31
32/**
33 * @implements {ScrobbleElement}
34 */
35class RockskyScrobbler extends BroadcastableDiffuseElement {
36 static NAME = "diffuse/supplement/rocksky";
37
38 // SIGNALS
39
40 #handle = signal(/** @type {string | null} */ (null));
41 #connected = signal(false);
42 #isAuthenticating = signal(false);
43
44 // STATE
45
46 handle = this.#handle.get;
47 isAuthenticated = this.#connected.get;
48 isAuthenticating = this.#isAuthenticating.get;
49
50 // LIFECYCLE
51
52 /** @override */
53 connectedCallback() {
54 // Broadcast if needed
55 if (this.hasAttribute("group")) {
56 const actions = this.broadcast(this.identifier, {
57 nowPlaying: { strategy: "leaderOnly", fn: this.nowPlaying },
58 scrobble: { strategy: "leaderOnly", fn: this.scrobble },
59
60 setHandle: { strategy: "replicate", fn: this.#handle.set },
61 setConnected: { strategy: "replicate", fn: this.#connected.set },
62 });
63
64 if (actions) {
65 this.nowPlaying = actions.nowPlaying;
66 this.scrobble = actions.scrobble;
67
68 this.#handle.set = actions.setHandle;
69 this.#connected.set = actions.setConnected;
70 }
71 }
72
73 super.connectedCallback();
74
75 this.#tryRestore();
76 }
77
78 async #tryRestore() {
79 await this.whenConnected();
80
81 try {
82 const session = await restoreOrFinalize();
83
84 if (session) {
85 const did = session.info.sub;
86
87 if (await this.isLeader()) {
88 this.#connected.set(true);
89 this.#handle.set(did);
90 } else {
91 this.#connected.value = true;
92 this.#handle.value = did;
93 }
94
95 await saveSession(this, STORAGE_KEY, JSON.stringify({ did }));
96 return;
97 }
98 } catch (err) {
99 console.warn("Rocksky: Failed to restore/finalize session", err);
100 }
101
102 // Restore previously stored connection state.
103 const stored = await readSession(this, STORAGE_KEY);
104
105 if (stored) {
106 try {
107 const { did } = JSON.parse(stored);
108
109 if (await this.isLeader()) {
110 this.#connected.set(true);
111 this.#handle.set(did);
112 } else {
113 this.#connected.value = true;
114 this.#handle.value = did;
115 }
116 } catch {
117 await clearSession(this, STORAGE_KEY);
118 }
119 }
120 }
121
122 // AUTH
123
124 /**
125 * Connect to Rocksky by initiating the AT Protocol OAuth flow for the given handle.
126 * Navigates the browser away to the authorization server.
127 *
128 * @param {string} handle
129 */
130 async signIn(handle) {
131 this.#isAuthenticating.set(true);
132
133 try {
134 await login(handle);
135 } finally {
136 this.#isAuthenticating.set(false);
137 }
138 }
139
140 /**
141 * Disconnect from Rocksky.
142 */
143 async signOut() {
144 const did = localStorage.getItem(DID_STORAGE_KEY);
145
146 if (did) {
147 getSession(/** @type {`did:${string}:${string}`} */ (did))
148 .then((session) => logout(new OAuthUserAgent(session)))
149 .catch(() => clearStoredSession());
150 } else {
151 clearStoredSession();
152 }
153
154 this.#connected.set(false);
155 this.#handle.set(null);
156 await clearSession(this, STORAGE_KEY);
157 }
158
159 // SCROBBLE ACTIONS
160
161 /**
162 * @param {Track} _track
163 */
164 // deno-lint-ignore no-unused-vars
165 async nowPlaying(_track) {
166 // Rocksky has no now-playing PDS record type; scrobbles are the source of truth.
167 }
168
169 /**
170 * @param {Track} track
171 * @param {number} startedAt Unix timestamp in milliseconds
172 */
173 async scrobble(track, startedAt) {
174 if (!this.#connected.value) return;
175
176 const did = localStorage.getItem(DID_STORAGE_KEY);
177 if (!did) return;
178
179 const session = await getSession(/** @type {`did:${string}:${string}`} */ (did));
180 const agent = new OAuthUserAgent(session);
181
182 const tags = track.tags ?? {};
183
184 // All five fields are required by the app.rocksky.scrobble lexicon.
185 if (
186 !tags.title ||
187 !tags.artist ||
188 !tags.album ||
189 !tags.albumartist ||
190 track.stats?.duration == null
191 ) return;
192
193 /** @type {Record<string, unknown>} */
194 const record = {
195 $type: "app.rocksky.scrobble",
196 title: tags.title,
197 artist: tags.artist,
198 album: tags.album,
199 albumArtist: tags.albumartist,
200 duration: track.stats.duration,
201 };
202
203 if (tags.track?.no != null) record.trackNumber = tags.track.no;
204 if (tags.disc?.no != null) record.discNumber = tags.disc.no;
205
206 record.createdAt = new Date(startedAt).toISOString();
207
208 const response = await agent.handle("/xrpc/com.atproto.repo.putRecord", {
209 method: "POST",
210 headers: { "Content-Type": "application/json" },
211 body: JSON.stringify({
212 repo: did,
213 collection: "app.rocksky.scrobble",
214 rkey: tidNow(),
215 record,
216 validate: false,
217 }),
218 });
219
220 if (!response.ok) {
221 const error = await response.json().catch(() => ({}));
222 throw new Error(`rocksky: scrobble failed ${response.status}: ${error.message ?? ""}`);
223 }
224 }
225}
226
227export default RockskyScrobbler;
228
229////////////////////////////////////////////
230// REGISTER
231////////////////////////////////////////////
232
233export const CLASS = RockskyScrobbler;
234export const NAME = "ds-rocksky-scrobbler";
235
236defineElement(NAME, CLASS);