@rocksky/sdk
v0.16.0
Published
TypeScript SDK for Rocksky — built on atcute: AppView reads, AT Protocol PDS writes (scrobble, like, follow, shout), a local dedup index, and Jetstream real-time sync.
Downloads
2,397
Maintainers
Readme
@rocksky/sdk
Official TypeScript SDK for Rocksky — a music scrobbling &
discovery platform on the AT Protocol. Built on
atcute: RockskyClient does unauthenticated
AppView reads, and Agent logs in with an app password and writes
app.rocksky.* records to the user's PDS.
Install
npm install @rocksky/sdk # or: bun add @rocksky/sdkQuickstart
import { RockskyClient, Agent } from "@rocksky/sdk";
// Reads — unauthenticated. new RockskyClient() uses https://api.rocksky.app.
const rk = new RockskyClient();
const stats = await rk.globalStats();
const top = await rk.topTracks(10, 0);
// Writes — log in with an app password (resolves the PDS automatically).
const agent = await Agent.login("alice.bsky.social", "app-password");
const uri = await agent.scrobble({
title: "Chaser", artist: "Calibro 35",
album: "Jazzploitation", albumArtist: "Calibro 35", duration: 182320,
});API
Reads — RockskyClient: the client now covers the whole app.rocksky.*
read surface. Typed methods include profile, scrobbles, songs, albums,
artists, topTracks, topArtists, search, globalStats, lovedSongs,
catalogAlbums, catalogArtists, catalogSongs, albumTracks, artistAlbums,
artistTracks, scrobbleFeed, scrobble (single by uri), follows,
followers, knownFollowers. Raw (unknown-returning) detail/long-tail
methods cover the rest: album, artist, song, feed, playlists,
playlist, stats, wrapped, scrobblesChart, recommendations,
neighbours, shouts, and more.
Every named method is sugar over the universal escape hatch rk.get(nsid,
params), which calls ANY read query by nsid and returns unknown.
Filtering: catalogSongs, catalogArtists, catalogAlbums, and
scrobbleFeed take an optional RSQL filter (string or Filter) — build one
fluently, with the per-endpoint field constants for discoverability:
import { Filter, RockskyClient, ScrobbleFields, SongFields } from "@rocksky/sdk";
const rk = new RockskyClient();
const filter = Filter.eq(SongFields.artist, "Daft Punk")
.and(Filter.gt(SongFields.duration, 200_000))
.or(Filter.in(SongFields.genre, ["house", "electro"]));
const songs = await rk.catalogSongs(50, 0, undefined, filter);
// filter sent: artist=="Daft Punk";duration=gt=200000,genre=in=(house,electro)
// scrobbles support dotted selectors into the joined track/user/artist:
await rk.scrobbleFeed(undefined, false, 50, 0,
Filter.eq(ScrobbleFields.trackArtist, "Daft Punk").and(Filter.ge(ScrobbleFields.date, "2025-01-01")));Operators: eq (supports * wildcards), ne, gt, ge, lt, le, in,
out, isNull, isNotNull, chained with .and() / .or() (parentheses
inserted automatically per RSQL precedence).
Typed date-window charts: topTracksInterval(limit, offset, interval) and
topArtistsInterval(...) take a DateInterval built with the Interval
factories — Interval.allTime(), Interval.lastDays(n), Interval.lastWeeks(n),
Interval.lastMonths(n), Interval.lastYears(n), Interval.range(start, end).
topTracks / topArtists remain all-time shorthands.
import { RockskyClient, Interval } from "@rocksky/sdk";
const rk = new RockskyClient();
const monthly = await rk.topTracksInterval(10, 0, Interval.lastMonths(1));
// Resolve a bare title + artist into full canonical metadata.
const song = await rk.matchSong("Chaser", "Calibro 35");matchSong(title, artist, mbId?, isrc?): resolves a bare title + artist into
full canonical metadata (album, artwork, duration, MBID, ISRC, links).
Auth-gated reads: pass an optional bearer access token —
new RockskyClient(appview, token) — and it is sent as
Authorization: Bearer <token>.
Writes — Agent: two scrobble paths. scrobble(rec) writes full metadata
you already have; scrobbleMatch(input, appview?) takes a ScrobbleMatchInput
object ({ title, artist, album?, mbId?, isrc?, timestamp? } — title/artist
required; album overrides the resolved album, mbId/isrc are match anchors,
timestamp is a scrobbled-at Unix-seconds time; the optional appview overrides
the AppView used for matching) and resolves full metadata via matchSong first,
then writes — e.g. agent.scrobbleMatch({ title: "Chaser", artist: "Calibro 35" }).
Plus
createSong/createAlbum/createArtist, like, follow,
shout/replyShout, setNowPlaying/clearNowPlaying, delete. Records are the
generated types from ./generated/types.
Identity hashes: songHash, albumHash, artistHash — lowercase-hex
SHA-256, identical to the server and every other Rocksky SDK.
Duplicate prevention + real-time sync
An optional local index (embedded classic-level
LevelDB) prevents duplicate writes and stays live off the firehose. It is
Node-only, so it lives on its own subpath — the main @rocksky/sdk entry stays
browser-safe:
import { RockskyIndex } from "@rocksky/sdk/dedup";
const idx = new RockskyIndex("./dedup");
await idx.open();
agent.useIndex(idx);
const stats = await agent.syncRepo(); // backfill from the repo CAR (com.atproto.sync.getRepo)
agent.hydrateFromJetstream(); // keep it live from Jetstream (all 4 servers)With an index attached, the write verbs skip records that already exist (return the existing URI) and a same-second scrobble of the same track isn't duplicated.
Node ≥ 22 (global WebSocket/fetch) or Bun.
Remote control
Build a controllable player or a remote UI over the remote-control WebSocket (see
remote-ws/PROTOCOL.md). Heartbeat, reconnect, and
the register handshake are handled for you.
import { RemotePlayer, RemoteController } from "@rocksky/sdk";
// A controllable player (advertises now-playing + obeys commands):
const player = new RemotePlayer({ token, name: "My Player" });
player.on("play", () => engine.play()).on("pause", () => engine.pause());
player.connect();
player.setNowPlaying({ title, artist, albumArt, durationMs, elapsedMs, isPlaying: true });
player.setStatus("playing");
// A controller (lists devices, picks the primary, sends commands):
const controller = new RemoteController({ token, name: "My Controller" });
controller.on("devices", ({ devices }) => render(devices));
controller.on("nowPlaying", ({ deviceId, track }) => show(deviceId, track));
controller.connect();
controller.setPrimary(deviceId);
controller.pause(deviceId);See examples/remote.ts.
Example
bun run examples/native.tsLicense
MIT.
