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.

at v4 236 lines 5.9 kB view raw
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);