@rocksky/sdk 0.7.0 → 0.7.2
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 +29 -2
- package/package.json +1 -1
- package/src/agent.test.ts +166 -0
- package/src/agent.ts +53 -4
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;
|
|
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
|
@@ -6114,8 +6114,9 @@ class Agent {
|
|
|
6114
6114
|
}
|
|
6115
6115
|
static async login(identifier, password) {
|
|
6116
6116
|
const actor = await actorResolver().resolve(identifier);
|
|
6117
|
-
const
|
|
6118
|
-
|
|
6117
|
+
const pds = actor.pds.replace(/\/+$/, "");
|
|
6118
|
+
const session = await PasswordSession.login({ service: pds, identifier, password });
|
|
6119
|
+
return new Agent(new Client({ handler: session }), session.did, session, pds);
|
|
6119
6120
|
}
|
|
6120
6121
|
useIndex(idx) {
|
|
6121
6122
|
this.idx = idx;
|
|
@@ -6160,6 +6161,7 @@ class Agent {
|
|
|
6160
6161
|
}
|
|
6161
6162
|
async scrobble(rec) {
|
|
6162
6163
|
const record2 = { ...rec, createdAt: rec.createdAt || nowISO() };
|
|
6164
|
+
await this.publishScrobbleMetadata(record2);
|
|
6163
6165
|
if (this.idx) {
|
|
6164
6166
|
const secs = Math.floor(Date.parse(record2.createdAt) / 1000);
|
|
6165
6167
|
const existing = await this.idx.scrobbleUri(this.did, record2.title, record2.artist, record2.album, secs);
|
|
@@ -6173,6 +6175,31 @@ class Agent {
|
|
|
6173
6175
|
}
|
|
6174
6176
|
return uri2;
|
|
6175
6177
|
}
|
|
6178
|
+
async publishScrobbleMetadata(record2) {
|
|
6179
|
+
const { albumArtist, album, title, artist, createdAt } = record2;
|
|
6180
|
+
if (albumArtist) {
|
|
6181
|
+
await this.createArtist({ name: albumArtist, createdAt });
|
|
6182
|
+
}
|
|
6183
|
+
if (albumArtist && album) {
|
|
6184
|
+
await this.createAlbum({
|
|
6185
|
+
title: album,
|
|
6186
|
+
artist: albumArtist,
|
|
6187
|
+
releaseDate: record2.releaseDate,
|
|
6188
|
+
year: record2.year,
|
|
6189
|
+
genre: record2.genre,
|
|
6190
|
+
albumArtUrl: record2.albumArtUrl,
|
|
6191
|
+
tags: record2.tags,
|
|
6192
|
+
youtubeLink: record2.youtubeLink,
|
|
6193
|
+
spotifyLink: record2.spotifyLink,
|
|
6194
|
+
tidalLink: record2.tidalLink,
|
|
6195
|
+
appleMusicLink: record2.appleMusicLink,
|
|
6196
|
+
createdAt
|
|
6197
|
+
});
|
|
6198
|
+
}
|
|
6199
|
+
if (title && artist && album) {
|
|
6200
|
+
await this.createSong({ ...record2 });
|
|
6201
|
+
}
|
|
6202
|
+
}
|
|
6176
6203
|
async scrobbleMatch(input, appview) {
|
|
6177
6204
|
const { title, artist, album, mbId, isrc, timestamp } = input;
|
|
6178
6205
|
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.2",
|
|
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
|
@@ -93,8 +93,12 @@ export class Agent {
|
|
|
93
93
|
*/
|
|
94
94
|
static async login(identifier: string, password: string): Promise<Agent> {
|
|
95
95
|
const actor = await actorResolver().resolve(identifier as never);
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
// The resolver may hand back a PDS URL with a trailing slash; strip it so
|
|
97
|
+
// callers that build `${pds}/xrpc/...` don't produce a `//xrpc` path (which
|
|
98
|
+
// some PDS hosts answer with 404).
|
|
99
|
+
const pds = actor.pds.replace(/\/+$/, "");
|
|
100
|
+
const session = await PasswordSession.login({ service: pds, identifier, password });
|
|
101
|
+
return new Agent(new Client({ handler: session }), session.did, session, pds);
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
/** Attach a local dedup index — write verbs then skip records that already exist. */
|
|
@@ -150,9 +154,18 @@ export class Agent {
|
|
|
150
154
|
if (!res.ok) throw new RockskyError(res.data);
|
|
151
155
|
}
|
|
152
156
|
|
|
153
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* Scrobble a play (app.rocksky.scrobble). Before writing the scrobble, this
|
|
159
|
+
* publishes the canonical metadata it implies — artist, then album, then song
|
|
160
|
+
* (in that dependency order) — so the play is self-contained in the user's
|
|
161
|
+
* PDS. Every write is deduplicated against the attached index: anything
|
|
162
|
+
* already present in the repo is skipped, never republished. createdAt
|
|
163
|
+
* defaults to now.
|
|
164
|
+
*/
|
|
154
165
|
async scrobble(rec: ScrobbleInput): Promise<string> {
|
|
155
166
|
const record = { ...rec, createdAt: rec.createdAt || nowISO() };
|
|
167
|
+
// Materialize artist -> album -> song first (deduped). Then the scrobble.
|
|
168
|
+
await this.publishScrobbleMetadata(record);
|
|
156
169
|
if (this.idx) {
|
|
157
170
|
const secs = Math.floor(Date.parse(record.createdAt) / 1000);
|
|
158
171
|
const existing = await this.idx.scrobbleUri(this.did, record.title!, record.artist!, record.album!, secs);
|
|
@@ -166,10 +179,46 @@ export class Agent {
|
|
|
166
179
|
return uri;
|
|
167
180
|
}
|
|
168
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Publish the artist/album/song records a scrobble implies, in dependency
|
|
184
|
+
* order, each deduplicated via {@link Agent.createArtist}/`createAlbum`/
|
|
185
|
+
* `createSong`. A record is written only when its identity fields are
|
|
186
|
+
* present — artist needs an album artist, album needs title + artist, song
|
|
187
|
+
* needs title + artist + album — because a record without a stable identity
|
|
188
|
+
* hash cannot be deduped and would be republished on every scrobble.
|
|
189
|
+
*/
|
|
190
|
+
private async publishScrobbleMetadata(record: ScrobbleInput & { createdAt: string }): Promise<void> {
|
|
191
|
+
const { albumArtist, album, title, artist, createdAt } = record;
|
|
192
|
+
if (albumArtist) {
|
|
193
|
+
await this.createArtist({ name: albumArtist, createdAt });
|
|
194
|
+
}
|
|
195
|
+
if (albumArtist && album) {
|
|
196
|
+
await this.createAlbum({
|
|
197
|
+
title: album,
|
|
198
|
+
artist: albumArtist,
|
|
199
|
+
releaseDate: record.releaseDate,
|
|
200
|
+
year: record.year,
|
|
201
|
+
genre: record.genre,
|
|
202
|
+
albumArtUrl: record.albumArtUrl,
|
|
203
|
+
tags: record.tags,
|
|
204
|
+
youtubeLink: record.youtubeLink,
|
|
205
|
+
spotifyLink: record.spotifyLink,
|
|
206
|
+
tidalLink: record.tidalLink,
|
|
207
|
+
appleMusicLink: record.appleMusicLink,
|
|
208
|
+
createdAt,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (title && artist && album) {
|
|
212
|
+
// A song record shares the scrobble's shape verbatim.
|
|
213
|
+
await this.createSong({ ...record });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
169
217
|
/** Scrobble from just a title + artist (album optional, plus optional
|
|
170
218
|
* `mbId`/`isrc` anchors): resolve full metadata via `matchSong`, then write.
|
|
171
219
|
* Matching uses the public AppView unless `appview` is given; an empty match
|
|
172
|
-
* falls back to a minimal record.
|
|
220
|
+
* falls back to a minimal record. Delegates to {@link Agent.scrobble}, so it
|
|
221
|
+
* publishes the implied artist/album/song (deduped) before the scrobble. */
|
|
173
222
|
async scrobbleMatch(input: ScrobbleMatchInput, appview?: string): Promise<string> {
|
|
174
223
|
const { title, artist, album, mbId, isrc, timestamp } = input;
|
|
175
224
|
const { RockskyClient } = await import("./client.js");
|