@rocksky/sdk 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocksky/sdk",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "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.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,166 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { Agent } from "./agent.js";
4
+ import type { ScrobbleInput } from "./agent.js";
5
+
6
+ const DID = "did:plc:test";
7
+
8
+ const C_ARTIST = "app.rocksky.artist";
9
+ const C_ALBUM = "app.rocksky.album";
10
+ const C_SONG = "app.rocksky.song";
11
+ const C_SCROBBLE = "app.rocksky.scrobble";
12
+
13
+ /**
14
+ * A fake XRPC client that captures every `createRecord` call instead of hitting
15
+ * a PDS. Nothing here talks to the network. Returns a fresh at:// URI per write.
16
+ */
17
+ function fakeAgent(idx?: unknown): {
18
+ agent: Agent;
19
+ created: { collection: string; record: Record<string, unknown> }[];
20
+ } {
21
+ const created: { collection: string; record: Record<string, unknown> }[] = [];
22
+ let n = 0;
23
+ const rpc = {
24
+ async post(nsid: string, opts: { input: { collection: string; record: Record<string, unknown> } }) {
25
+ if (nsid === "com.atproto.repo.createRecord") {
26
+ const { collection, record } = opts.input;
27
+ created.push({ collection, record });
28
+ return { ok: true, data: { uri: `at://${DID}/${collection}/rec${++n}` } };
29
+ }
30
+ return { ok: false, data: { error: "UnexpectedCall", message: nsid } };
31
+ },
32
+ };
33
+ // The constructor is private (compile-time only); bun runs the source
34
+ // directly, so we can instantiate with a stub client + no real session.
35
+ const agent = new (Agent as unknown as new (...a: unknown[]) => Agent)(rpc, DID, {}, "https://pds.test");
36
+ if (idx) agent.useIndex(idx as never);
37
+ return { agent, created };
38
+ }
39
+
40
+ /**
41
+ * In-memory stand-in for {@link RockskyIndex}: mirrors the exact identity
42
+ * semantics the Agent relies on (artist by name, album by title+artist, song by
43
+ * title+artist+album, scrobble by song+second), without LevelDB or a PDS.
44
+ */
45
+ function memIndex() {
46
+ const map = new Map<string, string>();
47
+ const k = (...parts: (string | number)[]) => parts.join("\x00");
48
+ return {
49
+ artistUri: async (_d: string, name: string) => map.get(k("artist", name)),
50
+ albumUri: async (_d: string, album: string, albumArtist: string) => map.get(k("album", album, albumArtist)),
51
+ songUri: async (_d: string, title: string, artist: string, album: string) =>
52
+ map.get(k("song", title, artist, album)),
53
+ scrobbleUri: async (_d: string, title: string, artist: string, album: string, secs: number) =>
54
+ map.get(k("scrobble", title, artist, album, secs)),
55
+ recordArtist: async (_d: string, name: string, uri: string) => void map.set(k("artist", name), uri),
56
+ recordAlbum: async (_d: string, album: string, albumArtist: string, uri: string) =>
57
+ void map.set(k("album", album, albumArtist), uri),
58
+ recordSong: async (_d: string, title: string, artist: string, album: string, uri: string) =>
59
+ void map.set(k("song", title, artist, album), uri),
60
+ recordScrobble: async (_d: string, title: string, artist: string, album: string, secs: number, uri: string) =>
61
+ void map.set(k("scrobble", title, artist, album, secs), uri),
62
+ };
63
+ }
64
+
65
+ const FULL: ScrobbleInput = {
66
+ title: "Song A",
67
+ artist: "Artist A",
68
+ albumArtist: "Artist A",
69
+ album: "Album A",
70
+ duration: 210_000,
71
+ year: 2021,
72
+ genre: "rock",
73
+ spotifyLink: "https://open.spotify.com/track/xyz",
74
+ albumArtUrl: "https://cdn.test/art.jpg",
75
+ createdAt: "2024-01-01T00:00:00.000Z",
76
+ };
77
+
78
+ const cols = (created: { collection: string }[]) => created.map((c) => c.collection);
79
+
80
+ describe("Agent.scrobble metadata publishing", () => {
81
+ test("publishes artist, album, song, then the scrobble — in that order", async () => {
82
+ const { agent, created } = fakeAgent(memIndex());
83
+ await agent.scrobble(FULL);
84
+ expect(cols(created)).toEqual([C_ARTIST, C_ALBUM, C_SONG, C_SCROBBLE]);
85
+ });
86
+
87
+ test("stamps $type on every published record", async () => {
88
+ const { agent, created } = fakeAgent(memIndex());
89
+ await agent.scrobble(FULL);
90
+ for (const { collection, record } of created) expect(record.$type).toBe(collection);
91
+ });
92
+
93
+ test("derives album (title=album, artist=albumArtist) and copies scrobble fields to song", async () => {
94
+ const { agent, created } = fakeAgent(memIndex());
95
+ await agent.scrobble(FULL);
96
+ const album = created.find((c) => c.collection === C_ALBUM)!.record;
97
+ expect(album.title).toBe("Album A");
98
+ expect(album.artist).toBe("Artist A");
99
+ expect(album.year).toBe(2021);
100
+ expect(album.spotifyLink).toBe("https://open.spotify.com/track/xyz");
101
+
102
+ const artist = created.find((c) => c.collection === C_ARTIST)!.record;
103
+ expect(artist.name).toBe("Artist A");
104
+
105
+ const song = created.find((c) => c.collection === C_SONG)!.record;
106
+ expect(song.title).toBe("Song A");
107
+ expect(song.artist).toBe("Artist A");
108
+ expect(song.album).toBe("Album A");
109
+ expect(song.duration).toBe(210_000);
110
+ // createdAt propagates from the scrobble to every derived record.
111
+ for (const { record } of created) expect(record.createdAt).toBe("2024-01-01T00:00:00.000Z");
112
+ });
113
+ });
114
+
115
+ describe("Agent.scrobble dedup (never republish what's already in the PDS)", () => {
116
+ test("a second play of the same song reuses artist/album/song, writes only the scrobble", async () => {
117
+ const idx = memIndex();
118
+ const { agent, created } = fakeAgent(idx);
119
+ await agent.scrobble(FULL);
120
+ created.length = 0; // ignore the first play's writes
121
+
122
+ await agent.scrobble({ ...FULL, createdAt: "2024-01-01T01:00:00.000Z" });
123
+ expect(cols(created)).toEqual([C_SCROBBLE]);
124
+ });
125
+
126
+ test("an exact-duplicate scrobble (same second) writes nothing and returns the existing uri", async () => {
127
+ const idx = memIndex();
128
+ const { agent, created } = fakeAgent(idx);
129
+ const uri1 = await agent.scrobble(FULL);
130
+ created.length = 0;
131
+
132
+ const uri2 = await agent.scrobble(FULL);
133
+ expect(uri2).toBe(uri1);
134
+ expect(created).toHaveLength(0);
135
+ });
136
+
137
+ test("without an index there is no dedup — every play republishes all four records", async () => {
138
+ const { agent, created } = fakeAgent();
139
+ await agent.scrobble(FULL);
140
+ await agent.scrobble(FULL);
141
+ expect(cols(created)).toEqual([
142
+ C_ARTIST,
143
+ C_ALBUM,
144
+ C_SONG,
145
+ C_SCROBBLE,
146
+ C_ARTIST,
147
+ C_ALBUM,
148
+ C_SONG,
149
+ C_SCROBBLE,
150
+ ]);
151
+ });
152
+ });
153
+
154
+ describe("Agent.scrobble identity guards (skip records that can't be deduped)", () => {
155
+ test("empty album skips album + song (no stable identity), still writes artist + scrobble", async () => {
156
+ const { agent, created } = fakeAgent(memIndex());
157
+ await agent.scrobble({ title: "T", artist: "A", albumArtist: "A", album: "", duration: 0 });
158
+ expect(cols(created)).toEqual([C_ARTIST, C_SCROBBLE]);
159
+ });
160
+
161
+ test("empty albumArtist skips artist + album, still writes song + scrobble", async () => {
162
+ const { agent, created } = fakeAgent(memIndex());
163
+ await agent.scrobble({ title: "T", artist: "A", albumArtist: "", album: "Alb", duration: 0 });
164
+ expect(cols(created)).toEqual([C_SONG, C_SCROBBLE]);
165
+ });
166
+ });
package/src/agent.ts CHANGED
@@ -150,9 +150,18 @@ export class Agent {
150
150
  if (!res.ok) throw new RockskyError(res.data);
151
151
  }
152
152
 
153
- /** Scrobble a play (app.rocksky.scrobble). createdAt defaults to now. */
153
+ /**
154
+ * Scrobble a play (app.rocksky.scrobble). Before writing the scrobble, this
155
+ * publishes the canonical metadata it implies — artist, then album, then song
156
+ * (in that dependency order) — so the play is self-contained in the user's
157
+ * PDS. Every write is deduplicated against the attached index: anything
158
+ * already present in the repo is skipped, never republished. createdAt
159
+ * defaults to now.
160
+ */
154
161
  async scrobble(rec: ScrobbleInput): Promise<string> {
155
162
  const record = { ...rec, createdAt: rec.createdAt || nowISO() };
163
+ // Materialize artist -> album -> song first (deduped). Then the scrobble.
164
+ await this.publishScrobbleMetadata(record);
156
165
  if (this.idx) {
157
166
  const secs = Math.floor(Date.parse(record.createdAt) / 1000);
158
167
  const existing = await this.idx.scrobbleUri(this.did, record.title!, record.artist!, record.album!, secs);
@@ -166,10 +175,46 @@ export class Agent {
166
175
  return uri;
167
176
  }
168
177
 
178
+ /**
179
+ * Publish the artist/album/song records a scrobble implies, in dependency
180
+ * order, each deduplicated via {@link Agent.createArtist}/`createAlbum`/
181
+ * `createSong`. A record is written only when its identity fields are
182
+ * present — artist needs an album artist, album needs title + artist, song
183
+ * needs title + artist + album — because a record without a stable identity
184
+ * hash cannot be deduped and would be republished on every scrobble.
185
+ */
186
+ private async publishScrobbleMetadata(record: ScrobbleInput & { createdAt: string }): Promise<void> {
187
+ const { albumArtist, album, title, artist, createdAt } = record;
188
+ if (albumArtist) {
189
+ await this.createArtist({ name: albumArtist, createdAt });
190
+ }
191
+ if (albumArtist && album) {
192
+ await this.createAlbum({
193
+ title: album,
194
+ artist: albumArtist,
195
+ releaseDate: record.releaseDate,
196
+ year: record.year,
197
+ genre: record.genre,
198
+ albumArtUrl: record.albumArtUrl,
199
+ tags: record.tags,
200
+ youtubeLink: record.youtubeLink,
201
+ spotifyLink: record.spotifyLink,
202
+ tidalLink: record.tidalLink,
203
+ appleMusicLink: record.appleMusicLink,
204
+ createdAt,
205
+ });
206
+ }
207
+ if (title && artist && album) {
208
+ // A song record shares the scrobble's shape verbatim.
209
+ await this.createSong({ ...record });
210
+ }
211
+ }
212
+
169
213
  /** Scrobble from just a title + artist (album optional, plus optional
170
214
  * `mbId`/`isrc` anchors): resolve full metadata via `matchSong`, then write.
171
215
  * Matching uses the public AppView unless `appview` is given; an empty match
172
- * falls back to a minimal record. */
216
+ * falls back to a minimal record. Delegates to {@link Agent.scrobble}, so it
217
+ * publishes the implied artist/album/song (deduped) before the scrobble. */
173
218
  async scrobbleMatch(input: ScrobbleMatchInput, appview?: string): Promise<string> {
174
219
  const { title, artist, album, mbId, isrc, timestamp } = input;
175
220
  const { RockskyClient } = await import("./client.js");
package/src/client.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Client, simpleFetchHandler } from "@atcute/client";
2
2
 
3
3
  import { RockskyError } from "./errors.js";
4
+ import { RockskyLibrary } from "./library.js";
4
5
  import type {
5
6
  ActorProfileViewBasic,
6
7
  ActorProfileViewDetailed,
@@ -58,14 +59,16 @@ export const DEFAULT_APPVIEW = "https://api.rocksky.app";
58
59
  /** Unauthenticated read client over the public Rocksky AppView XRPC. */
59
60
  export class RockskyClient {
60
61
  private rpc: Client;
62
+ private token?: string;
61
63
 
62
64
  /**
63
65
  * Build a read client against an AppView base URL (defaults to
64
66
  * {@link DEFAULT_APPVIEW}). Pass `token` to send it as
65
67
  * `Authorization: Bearer <token>` on every read — needed only for auth-gated
66
- * queries.
68
+ * queries and the whole {@link RockskyClient.library} surface.
67
69
  */
68
70
  constructor(appview: string = DEFAULT_APPVIEW, token?: string) {
71
+ this.token = token;
69
72
  let handler = simpleFetchHandler({ service: appview });
70
73
  if (token) {
71
74
  const inner = handler;
@@ -78,6 +81,20 @@ export class RockskyClient {
78
81
  this.rpc = new Client({ handler });
79
82
  }
80
83
 
84
+ /**
85
+ * The authenticated `app.rocksky.library.*` (uploaded-music) API. Every
86
+ * library method requires auth, so this throws unless the client was built
87
+ * with a token.
88
+ */
89
+ library(): RockskyLibrary {
90
+ if (!this.token) {
91
+ throw new Error(
92
+ "app.rocksky.library.* requires an access token; construct RockskyClient(appview, token) first",
93
+ );
94
+ }
95
+ return new RockskyLibrary(this.rpc);
96
+ }
97
+
81
98
  private async query<T>(nsid: string, params: Record<string, unknown>): Promise<T> {
82
99
  const clean: Record<string, unknown> = {};
83
100
  for (const [k, v] of Object.entries(params)) {