@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 +19 -2
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.test.d.ts +2 -0
- package/dist/agent.test.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/package.json +1 -1
- package/src/agent.test.ts +166 -0
- package/src/agent.ts +47 -2
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
|
-
/**
|
|
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>;
|
package/dist/agent.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAG3D,OAAO,KAAK,EACV,WAAW,EACX,YAAY,EACZ,cAAc,EACd,cAAc,EACd,UAAU,EACX,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC3D,OAAO,EAAgB,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AA+BrE,oEAAoE;AACpE,MAAM,MAAM,aAAa,GAAG,IAAI,CAAC,cAAc,EAAE,WAAW,CAAC,GAAG;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AACvF,wFAAwF;AACxF,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,mCAAmC;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,oDAAoD;IACpD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gCAAgC;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gDAAgD;IAChD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AACD,0CAA0C;AAC1C,MAAM,MAAM,SAAS,GAAG,IAAI,CAAC,UAAU,EAAE,WAAW,CAAC,GAAG;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAC/E,0EAA0E;AAC1E,MAAM,MAAM,UAAU,GAAG,IAAI,CAAC,WAAW,EAAE,WAAW,CAAC,GAAG;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AACjF,4CAA4C;AAC5C,MAAM,MAAM,WAAW,GAAG,IAAI,CAAC,YAAY,EAAE,WAAW,CAAC,GAAG;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAEnF;;;;GAIG;AACH,qBAAa,KAAK;IAId,OAAO,CAAC,GAAG;IACX,QAAQ,CAAC,GAAG,EAAE,MAAM;IACpB,QAAQ,CAAC,OAAO,EAAE,eAAe;IACjC,OAAO,CAAC,GAAG;IANb,OAAO,CAAC,GAAG,CAAC,CAAe;IAE3B,OAAO;IAOP;;;OAGG;WACU,KAAK,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;IAMxE,qFAAqF;IACrF,QAAQ,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI;IAIjC;;;;OAIG;IACG,QAAQ,IAAI,OAAO,CAAC,UAAU,CAAC;IASrC;;;;OAIG;IACH,oBAAoB,CAAC,IAAI,GAAE,gBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC;YAKlD,MAAM;YAQN,SAAS;IAQvB,4CAA4C;IACtC,MAAM,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAO7D
|
|
1
|
+
{"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"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"}
|
|
@@ -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.
|
|
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
|
-
/**
|
|
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");
|