@rocksky/sdk 0.7.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/dist/agent.d.ts CHANGED
@@ -66,12 +66,29 @@ export declare class Agent {
66
66
  private putRecord;
67
67
  /** Delete a record by collection + rkey. */
68
68
  delete(collection: string, rkey: string): Promise<void>;
69
- /** Scrobble a play (app.rocksky.scrobble). createdAt defaults to now. */
69
+ /**
70
+ * Scrobble a play (app.rocksky.scrobble). Before writing the scrobble, this
71
+ * publishes the canonical metadata it implies — artist, then album, then song
72
+ * (in that dependency order) — so the play is self-contained in the user's
73
+ * PDS. Every write is deduplicated against the attached index: anything
74
+ * already present in the repo is skipped, never republished. createdAt
75
+ * defaults to now.
76
+ */
70
77
  scrobble(rec: ScrobbleInput): Promise<string>;
78
+ /**
79
+ * Publish the artist/album/song records a scrobble implies, in dependency
80
+ * order, each deduplicated via {@link Agent.createArtist}/`createAlbum`/
81
+ * `createSong`. A record is written only when its identity fields are
82
+ * present — artist needs an album artist, album needs title + artist, song
83
+ * needs title + artist + album — because a record without a stable identity
84
+ * hash cannot be deduped and would be republished on every scrobble.
85
+ */
86
+ private publishScrobbleMetadata;
71
87
  /** Scrobble from just a title + artist (album optional, plus optional
72
88
  * `mbId`/`isrc` anchors): resolve full metadata via `matchSong`, then write.
73
89
  * Matching uses the public AppView unless `appview` is given; an empty match
74
- * falls back to a minimal record. */
90
+ * falls back to a minimal record. Delegates to {@link Agent.scrobble}, so it
91
+ * publishes the implied artist/album/song (deduped) before the scrobble. */
75
92
  scrobbleMatch(input: ScrobbleMatchInput, appview?: string): Promise<string>;
76
93
  /** Create a canonical track record (app.rocksky.song). */
77
94
  createSong(rec: SongInput): Promise<string>;
@@ -1 +1 @@
1
- {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=agent.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent.test.d.ts","sourceRoot":"","sources":["../src/agent.test.ts"],"names":[],"mappings":""}
package/dist/index.js CHANGED
@@ -6160,6 +6160,7 @@ class Agent {
6160
6160
  }
6161
6161
  async scrobble(rec) {
6162
6162
  const record2 = { ...rec, createdAt: rec.createdAt || nowISO() };
6163
+ await this.publishScrobbleMetadata(record2);
6163
6164
  if (this.idx) {
6164
6165
  const secs = Math.floor(Date.parse(record2.createdAt) / 1000);
6165
6166
  const existing = await this.idx.scrobbleUri(this.did, record2.title, record2.artist, record2.album, secs);
@@ -6173,6 +6174,31 @@ class Agent {
6173
6174
  }
6174
6175
  return uri2;
6175
6176
  }
6177
+ async publishScrobbleMetadata(record2) {
6178
+ const { albumArtist, album, title, artist, createdAt } = record2;
6179
+ if (albumArtist) {
6180
+ await this.createArtist({ name: albumArtist, createdAt });
6181
+ }
6182
+ if (albumArtist && album) {
6183
+ await this.createAlbum({
6184
+ title: album,
6185
+ artist: albumArtist,
6186
+ releaseDate: record2.releaseDate,
6187
+ year: record2.year,
6188
+ genre: record2.genre,
6189
+ albumArtUrl: record2.albumArtUrl,
6190
+ tags: record2.tags,
6191
+ youtubeLink: record2.youtubeLink,
6192
+ spotifyLink: record2.spotifyLink,
6193
+ tidalLink: record2.tidalLink,
6194
+ appleMusicLink: record2.appleMusicLink,
6195
+ createdAt
6196
+ });
6197
+ }
6198
+ if (title && artist && album) {
6199
+ await this.createSong({ ...record2 });
6200
+ }
6201
+ }
6176
6202
  async scrobbleMatch(input, appview) {
6177
6203
  const { title, artist, album, mbId, isrc, timestamp } = input;
6178
6204
  const { RockskyClient: RockskyClient2 } = await Promise.resolve().then(() => (init_client2(), exports_client));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocksky/sdk",
3
- "version": "0.7.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");