discogs-typescript 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thijs Wijnmaalen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,396 @@
1
+ # discogs-typescript
2
+
3
+ A modern, fully typed, zero-dependency TypeScript client for the [Discogs API v2](https://www.discogs.com/developers/).
4
+
5
+ Covers **every endpoint in the Discogs documentation** — all 60 of them, across Database,
6
+ Marketplace, Inventory Export, Inventory Upload, User Identity, User Collection, User Wantlist
7
+ and User Lists — plus all three authentication schemes.
8
+
9
+ - **Zero runtime dependencies.** Nothing but the platform.
10
+ - **Isomorphic.** Global `fetch` and Web Crypto only — Node 18+, Deno, Bun, browsers and edge
11
+ runtimes. No Node built-ins are imported.
12
+ - **Fully typed.** Hand-written interfaces for every request and response, with string-literal
13
+ unions for conditions, currencies, order statuses and sort keys.
14
+ - **ESM only**, with a single rolled-up `.d.ts`. There is no CommonJS build, though Node
15
+ 22.12+ can still `require()` it via `require(esm)` — the bundle has no top-level await.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pnpm add discogs-typescript
21
+ ```
22
+
23
+ ## Quick start
24
+
25
+ ```ts
26
+ import { DiscogsClient } from 'discogs-typescript'
27
+
28
+ const client = new DiscogsClient({
29
+ // Required — Discogs returns an empty response to requests without a User-Agent.
30
+ userAgent: 'MyApp/1.0 +https://example.com',
31
+ auth: { token: process.env.DISCOGS_TOKEN! }
32
+ })
33
+
34
+ const release = await client.database.getRelease(249504)
35
+ console.log(release.title, release.artists[0]?.name)
36
+
37
+ const results = await client.database.search({ artist: 'nirvana', type: 'release' })
38
+ ```
39
+
40
+ The `userAgent` is not optional politeness — Discogs answers requests without one with an
41
+ empty body, and rejects strings that look like a browser or a generic HTTP library. Use
42
+ something like `MyDiscogsClient/1.0 +https://mydiscogsclient.org`.
43
+
44
+ ## Authentication
45
+
46
+ All three schemes from the [authentication docs](https://www.discogs.com/developers/#page:authentication)
47
+ are supported. What you can do depends on which you pick:
48
+
49
+ | Credentials | Rate limit | Image URLs | Acts as a user |
50
+ | ----------------------- | ---------- | ---------- | --------------------------- |
51
+ | none | 25/min | no | no |
52
+ | consumer key + secret | 60/min | yes | no |
53
+ | personal access token | 60/min | yes | the token holder |
54
+ | OAuth 1.0a access token | 60/min | yes | any user who granted access |
55
+
56
+ ### Personal access token
57
+
58
+ The simplest option for scripts acting on your own account. Generate one under
59
+ [Developer Settings](https://www.discogs.com/settings/developers).
60
+
61
+ ```ts
62
+ new DiscogsClient({ userAgent, auth: { token: 'abcxyz123456' } })
63
+ ```
64
+
65
+ ### Consumer key and secret
66
+
67
+ Raises your rate limit and unlocks image URLs, but authenticates you as nobody — endpoints
68
+ that touch a user's data still need a token or OAuth.
69
+
70
+ ```ts
71
+ new DiscogsClient({ userAgent, auth: { consumerKey: 'foo123', consumerSecret: 'bar456' } })
72
+ ```
73
+
74
+ ### OAuth 1.0a
75
+
76
+ To act on behalf of other users, run the three-legged flow with `DiscogsOAuth`:
77
+
78
+ ```ts
79
+ import { DiscogsClient, DiscogsOAuth } from 'discogs-typescript'
80
+
81
+ const oauth = new DiscogsOAuth({ consumerKey, consumerSecret, userAgent })
82
+
83
+ // 1. Temporary request token (valid 15 minutes). Pass 'oob' if you have no callback URL.
84
+ const request = await oauth.getRequestToken('https://example.com/callback')
85
+
86
+ // 2. Send the user here to approve your app.
87
+ console.log(oauth.getAuthorizeUrl(request.oauthToken))
88
+
89
+ // 3. Discogs redirects back with ?oauth_verifier=… — exchange it.
90
+ const access = await oauth.getAccessToken({
91
+ oauthToken: request.oauthToken,
92
+ oauthTokenSecret: request.oauthTokenSecret,
93
+ verifier
94
+ })
95
+
96
+ // 4. Access tokens do not expire unless the user revokes them. Store and reuse.
97
+ const client = new DiscogsClient({
98
+ userAgent,
99
+ auth: {
100
+ consumerKey,
101
+ consumerSecret,
102
+ accessToken: access.oauthToken,
103
+ accessTokenSecret: access.oauthTokenSecret
104
+ }
105
+ })
106
+
107
+ await client.user.getIdentity() // confirms who you are authenticated as
108
+ ```
109
+
110
+ Requests are signed with `PLAINTEXT` by default, which is what the Discogs docs recommend —
111
+ everything runs over HTTPS anyway. Pass `signatureMethod: 'HMAC-SHA1'` in the auth object to
112
+ sign with HMAC-SHA1 instead (computed via Web Crypto).
113
+
114
+ You can also supply your own strategy:
115
+
116
+ ```ts
117
+ const client = new DiscogsClient({
118
+ userAgent,
119
+ auth: { authorize: ({ headers }) => headers.set('Authorization', 'Discogs token=…') }
120
+ })
121
+ ```
122
+
123
+ ## Endpoints
124
+
125
+ Every resource mirrors a section of the Discogs documentation.
126
+
127
+ ### `client.database`
128
+
129
+ `getRelease` · `getReleaseRating` · `updateReleaseRating` · `deleteReleaseRating` ·
130
+ `getCommunityReleaseRating` · `getReleaseStats` · `getMaster` · `getMasterVersions` ·
131
+ `getArtist` · `getArtistReleases` · `getLabel` · `getLabelReleases` · `search`
132
+
133
+ ```ts
134
+ const versions = await client.database.getMasterVersions(1000, {
135
+ country: 'Belgium',
136
+ sort: 'released',
137
+ sort_order: 'asc'
138
+ })
139
+
140
+ // Artist discographies mix masters and releases, discriminated by `type`.
141
+ const { releases } = await client.database.getArtistReleases(108713, { sort: 'year' })
142
+ for (const item of releases) {
143
+ if (item.type === 'master') console.log(item.main_release)
144
+ else console.log(item.format, item.label)
145
+ }
146
+ ```
147
+
148
+ `search` requires authentication as any user — an unauthenticated search fails with a 401.
149
+
150
+ ### `client.marketplace`
151
+
152
+ `getInventory` · `getListing` · `createListing` · `editListing` · `deleteListing` ·
153
+ `getOrder` · `editOrder` · `listOrders` · `getOrderMessages` · `addOrderMessage` · `getFee` ·
154
+ `getPriceSuggestions` · `getReleaseStats`
155
+
156
+ ```ts
157
+ const { listing_id } = await client.marketplace.createListing({
158
+ release_id: 249504,
159
+ condition: 'Near Mint (NM or M-)',
160
+ sleeve_condition: 'Very Good Plus (VG+)',
161
+ price: 12.5,
162
+ status: 'For Sale',
163
+ weight: 'auto' // or a number of grams
164
+ })
165
+
166
+ const fee = await client.marketplace.getFee(20, 'EUR') // omit the currency for USD
167
+ ```
168
+
169
+ Order ids are **strings** of the form `"1-1"`, not numbers. Which statuses an order can move
170
+ to is decided per order — read `order.next_status` rather than assuming a fixed table. Setting
171
+ `shipping` invoices the buyer and forces the status to `Invoice Sent`, so `shipping` and
172
+ `status` cannot be sent in the same `editOrder` call.
173
+
174
+ Order messages are a discriminated union on `type`:
175
+
176
+ ```ts
177
+ const { messages } = await client.marketplace.getOrderMessages('1-1')
178
+ for (const message of messages) {
179
+ if (message.type === 'shipping') console.log(message.original, '→', message.new)
180
+ else if (message.type === 'status') console.log(message.status_id, message.actor.username)
181
+ }
182
+ ```
183
+
184
+ ### `client.user`
185
+
186
+ `getIdentity` · `getProfile` · `editProfile` · `getSubmissions` · `getContributions`
187
+
188
+ ### `client.collection`
189
+
190
+ `getFolders` · `createFolder` · `getFolder` · `editFolder` · `deleteFolder` ·
191
+ `getItemsByRelease` · `getItemsByFolder` · `addReleaseToFolder` · `changeInstance` ·
192
+ `deleteInstance` · `getFields` · `editFieldInstance` · `getValue`
193
+
194
+ Folder `0` is the permanent "All" folder (nothing can be added to it) and folder `1` is
195
+ "Uncategorized"; both are exported as `FOLDER_ALL` and `FOLDER_UNCATEGORIZED`. Because a user
196
+ may own several copies of the same release, each copy in a folder is an _instance_ with its
197
+ own `instance_id`.
198
+
199
+ ```ts
200
+ const { instance_id } = await client.collection.addReleaseToFolder(username, 1, 249504)
201
+ await client.collection.changeInstance(username, 1, 249504, instance_id, { rating: 5 })
202
+ // Move it elsewhere by passing the destination as folder_id in the body:
203
+ await client.collection.changeInstance(username, 1, 249504, instance_id, { folder_id: 4 })
204
+ ```
205
+
206
+ ### `client.wantlist`
207
+
208
+ `getWants` · `addToWantlist` · `editWantlistItem` · `removeFromWantlist`
209
+
210
+ Note that `notes` is a plain string on wantlist items, but an array of custom-field values on
211
+ collection items. That asymmetry is in the API, and the types reflect it.
212
+
213
+ ### `client.lists`
214
+
215
+ `getUserLists` · `getList`
216
+
217
+ The index and detail endpoints name their fields differently — `date_added`/`date_changed`/
218
+ `id`/`uri` versus `created_ts`/`modified_ts`/`list_id`/`url`. Again, that is the API, not a
219
+ transcription slip.
220
+
221
+ ### `client.inventoryExport` and `client.inventoryUpload`
222
+
223
+ `create` · `list` · `get` · `downloadCsv` · `downloadRaw`, and
224
+ `add` · `change` · `delete` · `list` · `get`.
225
+
226
+ Both are asynchronous job APIs: submit, then poll.
227
+
228
+ ```ts
229
+ const { id } = await client.inventoryExport.create() // 409 if one is already running
230
+ const status = await client.inventoryExport.get(id!)
231
+ if (status?.finished_ts) {
232
+ const csv = await client.inventoryExport.downloadCsv(id!)
233
+ }
234
+
235
+ await client.inventoryUpload.add(
236
+ 'release_id,price,media_condition\n249504,12.50,Near Mint (NM or M-)\n'
237
+ )
238
+ ```
239
+
240
+ Upload CSVs must be comma-separated with a lower-case header row. `add` requires `release_id`,
241
+ `price` and `media_condition`; `change` requires `release_id` plus at least one field to
242
+ change; `delete` takes only `listing_id`.
243
+
244
+ Both `get` methods accept `ifModifiedSince` and resolve to `null` on a `304 Not Modified`.
245
+
246
+ ## Pagination
247
+
248
+ Paginated endpoints take `page` and `per_page` (default 50, maximum 100) and return a
249
+ `pagination` object. Walking pages is left to you:
250
+
251
+ ```ts
252
+ let page = 1
253
+ for (;;) {
254
+ const result = await client.database.getLabelReleases(1, { page, per_page: 100 })
255
+ for (const release of result.releases) console.log(release.title)
256
+ if (page >= result.pagination.pages) break
257
+ page++
258
+ }
259
+ ```
260
+
261
+ Discogs also sends an RFC 5988 `Link` header, which `parseLinkHeader` will read if you are
262
+ working with a raw response.
263
+
264
+ ## Rate limits
265
+
266
+ Discogs throttles by source IP over a rolling 60-second window: 60 requests per minute
267
+ authenticated, 25 unauthenticated. Exceeding it returns a 429, which this client raises as a
268
+ `DiscogsRateLimitError` carrying the headers that came with it.
269
+
270
+ This client does **not** queue or retry for you — it reports what the server said and lets
271
+ you decide. There are three ways to read that, in rough order of how often you will want them.
272
+
273
+ **`onResponse`** — fires after every request, before the body is read. This is the one to
274
+ reach for. It is the only accurate option while requests overlap, and it gives you the whole
275
+ response, so you always know which call the numbers belong to:
276
+
277
+ ```ts
278
+ const client = new DiscogsClient({
279
+ userAgent,
280
+ onResponse: ({ response, rateLimit }) => {
281
+ // response.url, .status and .headers are all available here
282
+ if (rateLimit && rateLimit.remaining < 5) console.warn('Slow down —', response.url)
283
+ }
284
+ })
285
+ ```
286
+
287
+ **`client.rateLimit`** — the most recent response's values, for a quick check between batches.
288
+ Because it only ever reflects the last response, it is racy under concurrency: with several
289
+ requests in flight you cannot tell which one it came from. Fine for a sequential script,
290
+ wrong for anything parallel.
291
+
292
+ ```ts
293
+ await client.database.getRelease(249504)
294
+ console.log(client.rateLimit) // { limit: 60, used: 13, remaining: 47 }
295
+ ```
296
+
297
+ **[`client.request()`](#escape-hatch)** — returns `{ data, response, rateLimit }` for a single
298
+ call, when you want the metadata inline rather than in a hook.
299
+
300
+ Resource methods deliberately return the parsed body rather than that envelope: paying a
301
+ `.data` on all 60 of them to carry metadata most calls ignore is not a good trade, and
302
+ `onResponse` covers the case better anyway.
303
+
304
+ ## Errors
305
+
306
+ Every non-2xx response throws a `DiscogsError` subclass carrying the status, the parsed body
307
+ and the raw `Response`. The message is taken from the API's `{ "message": … }` payload.
308
+
309
+ ```ts
310
+ import { DiscogsError, DiscogsNotFoundError, DiscogsRateLimitError } from 'discogs-typescript'
311
+
312
+ try {
313
+ await client.database.getRelease(1)
314
+ } catch (error) {
315
+ if (error instanceof DiscogsNotFoundError)
316
+ console.log(error.message) // "Release not found."
317
+ else if (error instanceof DiscogsRateLimitError) console.log(error.rateLimit)
318
+ else if (error instanceof DiscogsError) console.log(error.status, error.body)
319
+ else throw error
320
+ }
321
+ ```
322
+
323
+ `DiscogsAuthenticationError` (401), `DiscogsPermissionError` (403), `DiscogsNotFoundError`
324
+ (404), `DiscogsMethodNotAllowedError` (405), `DiscogsValidationError` (422),
325
+ `DiscogsRateLimitError` (429) and `DiscogsServerError` (5xx) all extend `DiscogsError`.
326
+
327
+ ## Escape hatch
328
+
329
+ Anything the typed resources do not cover — and the response headers they discard — is
330
+ reachable through `request`:
331
+
332
+ ```ts
333
+ const { data, response, rateLimit } = await client.request<Release>({
334
+ method: 'GET',
335
+ path: '/releases/249504',
336
+ query: { curr_abbr: 'EUR' }
337
+ })
338
+ console.log(response.headers.get('Link'))
339
+ ```
340
+
341
+ ## Other options
342
+
343
+ ```ts
344
+ new DiscogsClient({
345
+ userAgent,
346
+ auth,
347
+ baseUrl: 'https://api.discogs.com', // point at a proxy or a mock server
348
+ mediaType: 'plaintext', // 'discogs' (default) | 'html' | 'plaintext'
349
+ fetch: myFetch, // inject a custom fetch
350
+ onResponse
351
+ })
352
+ ```
353
+
354
+ `mediaType` selects the `Accept` header, which controls how Discogs renders markup inside text
355
+ fields such as release notes and artist profiles.
356
+
357
+ ## Development
358
+
359
+ ```bash
360
+ pnpm install
361
+ pnpm test # vitest, no network access
362
+ pnpm typecheck
363
+ pnpm lint
364
+ pnpm build # vite library build → dist/
365
+ ```
366
+
367
+ Runnable examples live in [`examples/`](./examples) — `pnpm tsx examples/search.ts` and
368
+ friends. They talk to the real API and need credentials in the environment.
369
+
370
+ TypeScript is deliberately held at 5.x while the rest of the toolchain tracks latest:
371
+ typescript-eslint refuses to load under TS 7 ([#10940](https://github.com/typescript-eslint/typescript-eslint/issues/10940))
372
+ and `@microsoft/api-extractor` cannot bundle declarations it emits, which would cost both
373
+ type-aware linting and the single rolled-up `.d.ts`. Worth revisiting once both support it.
374
+
375
+ ## Releasing
376
+
377
+ Releases are cut from a git tag. `npm version` writes the new version to `package.json`,
378
+ commits it, and creates the matching tag:
379
+
380
+ ```bash
381
+ npm version patch # or minor / major
382
+ git push --follow-tags
383
+ ```
384
+
385
+ Pushing a `v*` tag runs [`.github/workflows/release.yml`](./.github/workflows/release.yml),
386
+ which checks the tag against `package.json`, runs lint, typecheck, tests and the build,
387
+ publishes to npm, and opens a GitHub Release with generated notes.
388
+
389
+ The workflow publishes over [npm trusted publishing](https://docs.npmjs.com/trusted-publishers),
390
+ so there is no npm token in the repository — CI exchanges a short-lived GitHub OIDC token for
391
+ publish rights, and every release carries a provenance attestation linking the tarball back to
392
+ the commit and workflow run that produced it.
393
+
394
+ ## License
395
+
396
+ MIT