tmdb-kit 1.0.0 → 1.2.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.
Files changed (45) hide show
  1. package/README.md +455 -196
  2. package/dist/cli.js +3823 -0
  3. package/dist/client.d.cts +71 -0
  4. package/dist/client.d.ts +71 -0
  5. package/dist/errors.d.cts +86 -0
  6. package/dist/errors.d.ts +86 -0
  7. package/dist/helpers/cache.d.cts +28 -0
  8. package/dist/helpers/cache.d.ts +28 -0
  9. package/dist/helpers/pagination.d.cts +35 -0
  10. package/dist/helpers/pagination.d.ts +35 -0
  11. package/dist/helpers/retry.d.cts +47 -0
  12. package/dist/helpers/retry.d.ts +47 -0
  13. package/dist/helpers/timeout.d.cts +20 -0
  14. package/dist/helpers/timeout.d.ts +20 -0
  15. package/dist/http.d.cts +80 -0
  16. package/dist/http.d.ts +80 -0
  17. package/dist/index.cjs +1115 -768
  18. package/dist/index.cjs.map +27 -1
  19. package/dist/index.d.cts +13 -783
  20. package/dist/index.d.ts +13 -783
  21. package/dist/index.js +1074 -744
  22. package/dist/index.js.map +27 -1
  23. package/dist/query.d.cts +91 -0
  24. package/dist/query.d.ts +91 -0
  25. package/dist/resources/configuration.d.cts +17 -0
  26. package/dist/resources/configuration.d.ts +17 -0
  27. package/dist/resources/genres.d.cts +13 -0
  28. package/dist/resources/genres.d.ts +13 -0
  29. package/dist/resources/images.d.cts +17 -0
  30. package/dist/resources/images.d.ts +17 -0
  31. package/dist/resources/movies.d.cts +21 -0
  32. package/dist/resources/movies.d.ts +21 -0
  33. package/dist/resources/people.d.cts +14 -0
  34. package/dist/resources/people.d.ts +14 -0
  35. package/dist/resources/search.d.cts +15 -0
  36. package/dist/resources/search.d.ts +15 -0
  37. package/dist/resources/trending.d.cts +23 -0
  38. package/dist/resources/trending.d.ts +23 -0
  39. package/dist/resources/tv.d.cts +30 -0
  40. package/dist/resources/tv.d.ts +30 -0
  41. package/dist/search-guards.d.cts +32 -0
  42. package/dist/search-guards.d.ts +32 -0
  43. package/dist/types.d.cts +550 -0
  44. package/dist/types.d.ts +550 -0
  45. package/package.json +26 -19
package/README.md CHANGED
@@ -6,29 +6,70 @@
6
6
  [![node](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org)
7
7
  [![coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/isaced/tmdb-kit/actions/workflows/ci.yml)
8
8
 
9
- A minimal, fully typed TMDB SDK with zero runtime dependencies. Built for Node.js, Bun, Deno, and edge runtimes.
10
-
11
- `tmdb-kit` covers the most common TMDB workflows — movies, TV shows, search, trending, people, genres, and more — with a small, hand-crafted API designed for day-to-day use.
9
+ A minimal, fully typed SDK and read-only command-line interface (CLI) for The Movie Database (TMDB). Zero runtime dependencies. Built for Node.js, Bun, and edge runtimes.
10
+
11
+ `tmdb-kit` covers common TMDB workflows: movies, TV shows, search, trending, people, genres, and images. Use it as a library, or run the bundled `tmdb` command in your terminal.
12
+
13
+ ## Contents
14
+
15
+ - [Features](#features)
16
+ - [Install](#install)
17
+ - [SDK](#sdk)
18
+ - [Create a client](#create-a-client)
19
+ - [Authentication](#authentication)
20
+ - [Configuration](#configuration)
21
+ - [Custom transport](#custom-transport)
22
+ - [Cancellation](#cancellation)
23
+ - [Supported resources](#supported-resources)
24
+ - [Image URLs](#image-urls)
25
+ - [Search](#search)
26
+ - [Image sizes](#image-sizes)
27
+ - [Errors](#errors)
28
+ - [Optional helpers](#optional-helpers)
29
+ - [CLI](#cli)
30
+ - [Credentials and config](#credentials-and-config)
31
+ - [Commands](#commands)
32
+ - [Common options](#common-options)
33
+ - [Output and exit codes](#output-and-exit-codes)
34
+ - [Examples](#examples)
35
+ - [Development](#development)
36
+ - [References](#references)
12
37
 
13
38
  ## Features
14
39
 
15
- - **Zero dependencies** — nothing to audit, nothing to conflict.
16
- - **Universal runtime** — uses only `fetch`, `Headers`, `URL`, and `AbortSignal`. Works on Node.js 18+, Bun, Deno, Cloudflare Workers, Vercel Edge, and any fetch-compatible environment.
17
- - **Strict TypeScript** — full type safety with autocomplete for request options, response shapes, image sizes, and error handling.
18
- - **ESM + CommonJS** — dual-format output via tsdown, ready for any module system.
19
- - **Typed resources** — first-class methods for movies, TV, search, trending, people, genres, configuration, and images.
20
- - **Injectable transport** — swap the HTTP layer for tests, caching, or instrumentation without mocking `fetch`.
21
- - **Image helpers** — build CDN / proxy / signed URLs with per-request transform overrides.
22
- - **Search type guards** — narrow `search.multi()` results to movies, TV, or people with runtime-checked helpers.
23
- - **100% test coverage** — comprehensive unit tests with mocked fetch; no real TMDB requests.
40
+ - **Zero dependencies**: nothing to audit, nothing to conflict.
41
+ - **Universal runtime**: uses only `fetch`, `Headers`, `URL`, and `AbortSignal`. Works on Node.js 18+, Bun, Cloudflare Workers, Vercel Edge, and any fetch-compatible environment.
42
+ - **SDK and CLI**: typed resource methods for your app, plus `tmdb` and `tmdb-kit` binaries for terminal use.
43
+ - **Strict TypeScript**: full type safety with autocomplete for request options, response shapes, image sizes, and error handling.
44
+ - **ESM output**: modern module output with bundled type declarations.
45
+ - **Typed resources**: first-class methods for movies, TV, search, trending, people, genres, configuration, and images.
46
+ - **Injectable transport**: swap the HTTP layer for tests, caching, or instrumentation without mocking `fetch`.
47
+ - **Optional helpers**: async pagination, `Retry-After`-aware retries, `AbortSignal` timeouts, and an LRU memory cache — all opt-in and tree-shakeable.
48
+ - **Image helpers**: build content delivery network (CDN), proxy, or signed URLs with per-request transform overrides.
49
+ - **Search type guards**: narrow `search.multi()` results to movies, TV, or people with runtime-checked helpers.
50
+ - **Tested**: unit and resource tests mock `fetch`; an optional live integration suite validates real API behavior, including the helpers.
24
51
 
25
52
  ## Install
26
53
 
54
+ Install the SDK with npm:
55
+
27
56
  ```bash
28
57
  npm install tmdb-kit
29
58
  ```
30
59
 
31
- ## Quick Start
60
+ Install the CLI globally:
61
+
62
+ ```bash
63
+ npm install --global tmdb-kit
64
+ ```
65
+
66
+ The CLI is available as `tmdb`.
67
+
68
+ ## SDK
69
+
70
+ Create a client, authenticate, and call typed resource methods. The client uses the Fetch API and has no runtime dependencies.
71
+
72
+ ### Create a client
32
73
 
33
74
  ```ts
34
75
  import { createTMDB } from 'tmdb-kit'
@@ -46,79 +87,60 @@ const movie = await tmdb.movies.details(popular.results[0]!.id, {
46
87
  const posterUrl = tmdb.images.url(movie.poster_path, 'w500')
47
88
  ```
48
89
 
49
- CommonJS:
90
+ ### Authentication
50
91
 
51
- ```js
52
- const { createTMDB } = require('tmdb-kit')
92
+ The SDK targets the TMDB v3 API at `api.themoviedb.org/3`. Two authentication methods are supported.
53
93
 
54
- const tmdb = createTMDB({ accessToken: process.env.TMDB_ACCESS_TOKEN })
55
- ```
56
-
57
- Deno:
94
+ An API Read Access Token is recommended and works with both v3 and v4:
58
95
 
59
96
  ```ts
60
- import { createTMDB } from 'npm:tmdb-kit'
61
-
62
- const tmdb = createTMDB({ accessToken: Deno.env.get('TMDB_ACCESS_TOKEN')! })
97
+ createTMDB({ accessToken: 'your_access_token_here' })
63
98
  ```
64
99
 
65
- ## Authentication
66
-
67
- The SDK targets the **TMDB v3 API** (`api.themoviedb.org/3`). Two authentication methods are supported:
68
-
69
- **API Read Access Token** (recommended) — works with both v3 and v4:
100
+ A legacy v3 API key is sent as an `api_key` query parameter:
70
101
 
71
102
  ```ts
72
- createTMDB({ accessToken: 'tmdb-read-access-token' })
103
+ createTMDB({ apiKey: 'your_api_key_here' })
73
104
  ```
74
105
 
75
- **Legacy v3 API key** — sent as an `api_key` query parameter:
106
+ Pass exactly one of `accessToken` or `apiKey`. The client throws when both or neither are set. When you supply a `transport`, credentials are optional because the transport owns authentication.
76
107
 
77
- ```ts
78
- createTMDB({ apiKey: 'tmdb-v3-api-key' })
79
- ```
108
+ ### Configuration
80
109
 
81
- Exactly one of `accessToken` or `apiKey` is required.
110
+ `createTMDB` accepts these options:
82
111
 
83
- ## Configuration
112
+ | Option | Default | Description |
113
+ | ------------------ | --------------------------------------------------- | ------------------------------------------------------------------------------------------ |
114
+ | `accessToken` | required unless `apiKey` or `transport` is set | API Read Access Token, sent as a Bearer header. |
115
+ | `apiKey` | required unless `accessToken` or `transport` is set | Legacy v3 API key, sent as `api_key`. |
116
+ | `baseUrl` | `https://api.themoviedb.org/3` | API base URL. Useful for proxies and tests. |
117
+ | `fetch` | `globalThis.fetch` at request time | Custom fetch implementation. |
118
+ | `defaultLanguage` | TMDB default | Language for endpoints that accept `language`. |
119
+ | `defaultRegion` | TMDB default | Region for movie lists and search endpoints that accept `region`. |
120
+ | `imageBaseUrl` | `https://image.tmdb.org/t/p` | Image host used by `tmdb.images`. |
121
+ | `images.transform` | none | Default URL transform for `tmdb.images.url()`. |
122
+ | `headers` | none | Extra headers merged into every request before authentication. |
123
+ | `transport` | built-in HTTP client | Custom transport; credentials are not required. See [Custom transport](#custom-transport). |
84
124
 
85
- The factory accepts a few options beyond authentication:
125
+ A typical client:
86
126
 
87
127
  ```ts
88
- import { createTMDB } from 'tmdb-kit'
89
-
90
128
  const tmdb = createTMDB({
91
- accessToken: 'tmdb-read-access-token',
92
-
93
- // Defaults to https://api.themoviedb.org/3. Useful for tests or proxies.
94
- baseUrl: 'https://api.themoviedb.org/3',
95
-
96
- // Inject a fetch implementation. The SDK reads `globalThis.fetch` lazily
97
- // on every request, so test-time mocks (vi.spyOn, etc.) are picked up
98
- // automatically after the client has been constructed.
99
- fetch: customFetch,
100
-
101
- // Defaults applied to every request that supports them.
129
+ accessToken: 'your_access_token_here',
102
130
  defaultLanguage: 'en-US',
103
131
  defaultRegion: 'US',
104
-
105
- // Custom image host. Defaults to https://image.tmdb.org/t/p.
106
- imageBaseUrl: 'https://image.tmdb.org/t/p',
107
-
108
- // Image URL transform applied by the ImagesHelper. See "Image URLs".
132
+ headers: { 'X-Client': 'my-app' },
109
133
  images: {
110
134
  transform: (url) => url.replace('image.tmdb.org/t/p', 'img-proxy.example/tmdb'),
111
135
  },
112
-
113
- // Extra headers merged into every request before authentication.
114
- headers: { 'X-Client': 'my-app' },
115
136
  })
116
137
  ```
117
138
 
118
- ### Injecting a custom transport
139
+ The SDK reads `globalThis.fetch` lazily on every request. Spies on `globalThis.fetch` are picked up after the client has been constructed.
119
140
 
120
- For tests, caching, or instrumentation, you can swap the entire transport
121
- layer instead of mocking `fetch`:
141
+ ### Custom transport
142
+
143
+ For tests, caching, or instrumentation, swap the entire transport layer instead of mocking `fetch`:
122
144
 
123
145
  ```ts
124
146
  import { createTMDB, type TMDBTransport } from 'tmdb-kit'
@@ -128,64 +150,39 @@ const stub: TMDBTransport = {
128
150
  get: async (path, _options) => ({ results: [] }),
129
151
  }
130
152
 
131
- const tmdb = createTMDB({ accessToken: 'x', transport: stub })
153
+ const tmdb = createTMDB({ transport: stub })
132
154
  ```
133
155
 
134
- `transport.get()` is called for every resource and for the `request()`
135
- escape hatch, so a single stub covers the whole surface area.
136
- `TMDBTransport` is exported as part of the public API.
156
+ `transport.get()` runs for every resource and for `request()`, so a single stub covers the whole surface. `TMDBTransport` is part of the public API. The built-in `TMDBHttpClient` is exported too, so you can wrap it instead of reimplementing authentication.
157
+
158
+ ### Cancellation
137
159
 
138
- ## Supported Resources
160
+ Every resource method accepts an `AbortSignal` through its options. Pass the same signal to cancel one or more requests:
139
161
 
140
162
  ```ts
141
- tmdb.movies.popular()
142
- tmdb.movies.topRated()
143
- tmdb.movies.nowPlaying()
144
- tmdb.movies.upcoming()
145
- tmdb.movies.details(550, { appendToResponse: ['credits', 'videos'] })
146
- tmdb.movies.credits(550)
147
- tmdb.movies.images(550)
148
- tmdb.movies.recommendations(550)
149
- tmdb.movies.similar(550)
150
- tmdb.movies.videos(550)
151
-
152
- tmdb.tv.popular()
153
- tmdb.tv.topRated()
154
- tmdb.tv.airingToday()
155
- tmdb.tv.onTheAir()
156
- tmdb.tv.details(1399)
157
- tmdb.tv.credits(1399)
158
- tmdb.tv.images(1399)
159
- tmdb.tv.recommendations(1399)
160
- tmdb.tv.similar(1399)
161
- tmdb.tv.videos(1399)
162
- tmdb.tv.seasonDetails(1399, 1)
163
-
164
- tmdb.search.movies('Dune')
165
- tmdb.search.tv('Dark')
166
- tmdb.search.people('Sofia Coppola')
167
- tmdb.search.multi('Tom Hardy')
168
-
169
- tmdb.trending.all('week')
170
- tmdb.trending.movies('day')
171
- tmdb.trending.tv('day')
172
- tmdb.trending.people('week')
173
-
174
- tmdb.people.popular()
175
- tmdb.people.details(31)
176
-
177
- tmdb.genres.movies()
178
- tmdb.genres.tv()
179
-
180
- tmdb.configuration.details()
181
- tmdb.configuration.countries()
182
- tmdb.configuration.jobs()
183
- tmdb.configuration.languages()
184
- tmdb.configuration.primaryTranslations()
185
- tmdb.configuration.timezones()
163
+ const controller = new AbortController()
164
+ const promise = tmdb.movies.popular({ signal: controller.signal })
165
+
166
+ controller.abort()
167
+ await promise // rejects with an AbortError
186
168
  ```
187
169
 
188
- For an endpoint that is not wrapped yet:
170
+ `client.request()` accepts the same `signal`. The SDK does not impose a timeout by default; use the optional `withTimeout()` helper (see [Optional helpers](#optional-helpers)) or your runtime's `AbortSignal.timeout()` when you need one.
171
+
172
+ ### Supported resources
173
+
174
+ | Resource | Methods |
175
+ | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
176
+ | `tmdb.movies` | `popular()`, `topRated()`, `nowPlaying()`, `upcoming()`, `details()`, `credits()`, `images()`, `recommendations()`, `similar()`, `videos()` |
177
+ | `tmdb.tv` | `popular()`, `topRated()`, `airingToday()`, `onTheAir()`, `details()`, `credits()`, `images()`, `recommendations()`, `similar()`, `videos()`, `seasonDetails()`, `seasonCredits()`, `seasonAggregateCredits()`, `seasonImages()`, `seasonVideos()`, `episodeDetails()`, `aggregateCredits()`, `episodeGroups()`, `episodeGroup()` |
178
+ | `tmdb.search` | `movies()`, `tv()`, `people()`, `multi()` |
179
+ | `tmdb.trending` | `all()`, `movies()`, `tv()`, `people()` |
180
+ | `tmdb.people` | `popular()`, `details()`, `combinedCredits()` |
181
+ | `tmdb.genres` | `movies()`, `tv()` |
182
+ | `tmdb.configuration` | `details()`, `countries()`, `jobs()`, `languages()`, `primaryTranslations()`, `timezones()` |
183
+ | `tmdb.images` | `url()` |
184
+
185
+ For an endpoint that is not wrapped yet, use the `request()` escape hatch:
189
186
 
190
187
  ```ts
191
188
  const response = await tmdb.request<{ results: unknown[] }>('/discover/movie', {
@@ -196,34 +193,29 @@ const response = await tmdb.request<{ results: unknown[] }>('/discover/movie', {
196
193
  })
197
194
  ```
198
195
 
199
- ## Image URLs
196
+ ### Image URLs
200
197
 
201
- `tmdb.images.url(path, size)` builds a fully-qualified image URL. The
202
- helper returns `null` for nullish paths so UI code can branch naturally:
198
+ `tmdb.images.url(path, size)` builds a fully qualified image URL. It returns `null` for nullish paths so UI code can branch naturally:
203
199
 
204
200
  ```ts
205
201
  const posterUrl = tmdb.images.url(movie.poster_path, 'w500')
206
202
  ```
207
203
 
208
- ### Routing images through a CDN or proxy
209
-
210
- Pass a `transform` callback to rewrite the resolved URL. The most common
211
- use case is pointing TMDB image requests at a self-hosted proxy:
204
+ Pass a `transform` callback to route images through a CDN or proxy:
212
205
 
213
206
  ```ts
214
207
  const tmdb = createTMDB({
215
- accessToken: 'x',
208
+ accessToken: 'your_access_token_here',
216
209
  images: {
217
210
  transform: (url) => url.replace('https://image.tmdb.org/t/p', 'https://img-proxy.example/tmdb'),
218
211
  },
219
212
  })
220
213
 
221
214
  tmdb.images.url('/abc.jpg', 'w500')
222
- // → 'https://img-proxy.example/tmdb/w500/abc.jpg'
215
+ // 'https://img-proxy.example/tmdb/w500/abc.jpg'
223
216
  ```
224
217
 
225
- You can also pass a one-off transform per call — useful for adding
226
- signed query parameters for selected sizes:
218
+ Pass a one-off transform per call to add signed query parameters:
227
219
 
228
220
  ```ts
229
221
  tmdb.images.url('/abc.jpg', 'original', {
@@ -231,25 +223,14 @@ tmdb.images.url('/abc.jpg', 'original', {
231
223
  })
232
224
  ```
233
225
 
234
- The transform is never invoked for nullish paths, so `null` results stay
235
- `null`.
236
-
237
- `buildImageUrl(path, size, options)` is exported directly for callers
238
- that want to build URLs without a `TMDBClient` instance.
226
+ The transform is never invoked for nullish paths, so `null` results stay `null`. `buildImageUrl(path, size, options)` is exported for callers that build URLs without a `TMDBClient` instance.
239
227
 
240
- ## Search
228
+ ### Search
241
229
 
242
- `search.multi()` returns a union of movie / TV / person results. Filter
243
- out media types you do not want and narrow the result type with the
244
- provided type guards:
230
+ `search.multi()` returns a union of movie, TV, and person results. Filter media types and narrow the result type with the provided type guards:
245
231
 
246
232
  ```ts
247
- import {
248
- createTMDB,
249
- isMovieSearchResult,
250
- isTVSearchResult,
251
- isPersonSearchResult,
252
- } from 'tmdb-kit'
233
+ import { createTMDB, isMovieSearchResult, isTVSearchResult, isPersonSearchResult } from 'tmdb-kit'
253
234
 
254
235
  const { results } = await tmdb.search.multi('Inception', {
255
236
  exclude: ['person'],
@@ -260,104 +241,382 @@ const tvShows = results.filter(isTVSearchResult) // TVMultiResult[]
260
241
  const people = results.filter(isPersonSearchResult) // PersonMultiResult[]
261
242
  ```
262
243
 
263
- - `exclude` performs client-side filtering — the TMDB multi-search
264
- endpoint has no server-side media_type filter. `total_results` reflects
265
- the unfiltered payload; the filtered count is just `results.length`.
266
- - Short-name aliases `isMovie` / `isTV` / `isPerson` are also exported
267
- for callers that prefer them.
268
- - The single-resource search methods (`search.movies`, `search.tv`,
269
- `search.people`) already return the narrowed result type, so the
270
- guards are only useful for `search.multi()` payloads.
244
+ `exclude` performs client-side filtering because the TMDB multi-search endpoint has no server-side media type filter. `total_results` reflects the unfiltered payload, and the filtered count is `results.length`.
271
245
 
272
- ## Image Sizes
246
+ The short-name aliases `isMovie`, `isTV`, and `isPerson` are also exported. The single-resource search methods already return narrowed types, so the guards matter only for `search.multi()`.
273
247
 
274
- The `ImageSize` type is intentionally permissive — TMDB has added new
275
- sizes in the past (e.g. `h632` for profile) without notice, and the SDK
276
- keeps the `(string & {})` escape hatch so any future size keeps
277
- working at runtime. If you need the canonical set of documented sizes,
278
- the SDK ships them as readonly constants:
248
+ ### Image sizes
249
+
250
+ The `ImageSize` type is intentionally permissive. TMDB has added sizes without notice, such as `h632` for profiles, so the SDK keeps the `(string & {})` escape hatch for forward compatibility. The SDK also ships the documented sizes as readonly constants:
279
251
 
280
252
  ```ts
281
- import {
282
- KNOWN_BACKDROP_SIZES,
283
- KNOWN_LOGO_SIZES,
284
- KNOWN_POSTER_SIZES,
285
- KNOWN_PROFILE_SIZES,
286
- KNOWN_STILL_SIZES,
287
- KNOWN_IMAGE_SIZES,
288
- isKnownBackdropSize,
289
- isKnownImageSize,
290
- // …plus one helper per type
291
- } from 'tmdb-kit'
292
-
293
- // Iterate to build a size picker
253
+ import { KNOWN_POSTER_SIZES, isKnownPosterSize } from 'tmdb-kit'
254
+
294
255
  for (const size of KNOWN_POSTER_SIZES) {
295
256
  console.log(size) // 'w92' | 'w154' | 'w185' | 'w342' | 'w500' | 'w780' | 'original'
296
257
  }
297
258
 
298
- // Validate user-supplied sizes at runtime
299
259
  if (!isKnownPosterSize(userInput)) {
300
260
  throw new Error(`Unknown TMDB poster size: ${userInput}`)
301
261
  }
302
262
  ```
303
263
 
304
- `KnownBackdropSize` / `KnownPosterSize` / `KnownLogoSize` /
305
- `KnownProfileSize` / `KnownStillSize` / `KnownImageSize` are the
306
- matching TS literal-union types for callers that want strict typing
307
- without giving up forward-compatibility.
264
+ The SDK also exports `KNOWN_BACKDROP_SIZES`, `KNOWN_LOGO_SIZES`, `KNOWN_PROFILE_SIZES`, `KNOWN_STILL_SIZES`, `KNOWN_IMAGE_SIZES`, and a matching type guard for each list. The literal-union types are `KnownBackdropSize`, `KnownPosterSize`, `KnownLogoSize`, `KnownProfileSize`, `KnownStillSize`, and `KnownImageSize`.
308
265
 
309
- ## Errors
266
+ ### Errors
310
267
 
311
268
  ```ts
312
- import {
313
- TMDBRateLimitError,
314
- TMDBRequestError,
315
- TMDBResponseError,
316
- } from 'tmdb-kit'
269
+ import { TMDBRateLimitError, TMDBRequestError, TMDBResponseError, TMDBTimeoutError } from 'tmdb-kit'
317
270
 
318
271
  try {
319
272
  await tmdb.movies.popular()
320
273
  } catch (error) {
321
274
  if (error instanceof TMDBRateLimitError) {
322
- // 429 — TMDB asked the caller to back off. The SDK does NOT retry
323
- // automatically; back off for `retryAfter` seconds (or use your own
324
- // policy) and try again.
275
+ // 429: TMDB asked the caller to back off. The SDK does not retry by
276
+ // default; use retry() or withRetry() if you want automatic backoff.
325
277
  console.log('rate limited; retry in', error.retryAfter, 's')
326
278
  } else if (error instanceof TMDBResponseError) {
327
279
  console.log(error.status, error.statusCode, error.statusMessage)
328
280
  } else if (error instanceof TMDBRequestError) {
329
- // Thrown when a request fails before receiving a response (e.g. network
330
- // error, no fetch implementation found, or invalid client configuration).
281
+ // Network failure, missing fetch, or invalid client configuration.
331
282
  console.log('request error:', error.message)
283
+ } else if (error instanceof TMDBTimeoutError) {
284
+ // Thrown by withTimeout(), not by the HTTP layer.
285
+ console.log('timed out after', error.timeoutMs, 'ms')
332
286
  }
333
287
  }
334
288
  ```
335
289
 
336
- `TMDBResponseError` exposes convenience getters for the most common
337
- status codes:
290
+ `TMDBResponseError` exposes convenience getters for common status codes:
291
+
292
+ | Getter | Status |
293
+ | ---------------- | ------ |
294
+ | `isUnauthorized` | 401 |
295
+ | `isForbidden` | 403 |
296
+ | `isNotFound` | 404 |
297
+ | `isRateLimit` | 429 |
298
+ | `isServerError` | 5xx |
299
+
300
+ All errors extend `TMDBError`, which you can use as a single catch-all. `TMDBTimeoutError` is thrown by `withTimeout()` and exposes `timeoutMs`; the HTTP layer never throws it directly.
301
+
302
+ ### Optional helpers
303
+
304
+ These helpers are opt-in and never change the default client behavior. They are exported from the package root and tree-shake out of ESM bundles when unused.
305
+
306
+ **Pagination**
307
+
308
+ ```ts
309
+ import { paginate, paginateItems } from 'tmdb-kit'
310
+
311
+ for await (const page of paginate((page) => tmdb.movies.popular({ page, region: 'CN' }))) {
312
+ console.log(page.page, page.results.length)
313
+ }
314
+
315
+ // Or iterate items directly.
316
+ for await (const movie of paginateItems((page) => tmdb.movies.popular({ page }))) {
317
+ console.log(movie.title)
318
+ }
319
+ ```
320
+
321
+ `startPage` defaults to 1. `paginate` stops at `total_pages`, an empty page, `endPage` (default 500), or an aborted signal. `endPage` is the last page number to request, so it yields nothing when it is lower than `startPage`.
322
+
323
+ **Retry**
324
+
325
+ ```ts
326
+ import { createTMDB, retry, TMDBHttpClient, withRetry } from 'tmdb-kit'
327
+
328
+ // Wrap one call.
329
+ const movie = await retry(() => tmdb.movies.details(550), { retries: 3 })
330
+
331
+ // Or wrap the transport once. The transport owns authentication, so the
332
+ // factory does not need credentials.
333
+ const http = new TMDBHttpClient({ accessToken: 'your_access_token_here' })
334
+ const retrying = createTMDB({ transport: withRetry(http, { retries: 3 }) })
335
+ ```
336
+
337
+ Retries 429 and any response that carries `Retry-After` (including 503), retries 408, 5xx and network failures with exponential backoff, and leaves other errors untouched. Defaults are 2 retries, a 250 ms base delay, factor 2, full jitter, and a 60 s cap on `Retry-After`; use `retryOn` to replace the predicate and `onRetry` to observe delays. A wrapper-level `signal` and a per-request `signal` are combined, so aborting either cancels the call. The SDK itself still never retries by default.
338
+
339
+ **Timeout**
340
+
341
+ ```ts
342
+ import { withTimeout } from 'tmdb-kit'
343
+
344
+ const movie = await withTimeout(5_000, (signal) => tmdb.movies.details(550, { signal }))
345
+ ```
346
+
347
+ Timeouts reject with `TMDBTimeoutError`. The task must forward the signal to the SDK. Pass a parent `signal` in the options to cancel it externally; the parent's reason is preserved.
348
+
349
+ **Memory cache**
350
+
351
+ ```ts
352
+ import { createTMDB, TMDBHttpClient, withMemoryCache } from 'tmdb-kit'
353
+
354
+ const http = new TMDBHttpClient({ accessToken: 'your_access_token_here' })
355
+ const cached = createTMDB({
356
+ transport: withMemoryCache(http, { ttlMs: 60_000, maxEntries: 200 }),
357
+ })
358
+ ```
359
+
360
+ Defaults are a 5-minute TTL and 100 entries. The cache key includes the path, query, and headers, only successful responses are stored, and `clear()` invalidates every entry. Use one cache instance per client or credential: authentication is not part of the key.
361
+
362
+ ## CLI
363
+
364
+ The `tmdb` command is a read-only client for the same TMDB API. It reads credentials from the environment or a config file, prints human-readable summaries by default, and accepts `--format json` (or its `--json` alias) for raw TMDB payloads and `--format ndjson` for line-oriented output.
365
+
366
+ The CLI runs on Node.js 18+ and Bun.
367
+
368
+ ### Credentials and config
369
+
370
+ The CLI checks credentials in this order:
371
+
372
+ 1. `TMDB_ACCESS_TOKEN`
373
+ 2. `TMDB_API_KEY` (legacy v3 API key)
374
+ 3. `accessToken` in the config file
375
+ 4. `apiKey` in the config file
376
+
377
+ Environment variables take precedence over the config file. `TMDB_BASE_URL` overrides the API base URL for every command.
378
+
379
+ The config file lives at:
380
+
381
+ - Windows: `%APPDATA%\tmdb-kit\config.json`
382
+ - macOS and Linux: `$XDG_CONFIG_HOME/tmdb-kit/config.json`, or `~/.config/tmdb-kit/config.json` when `XDG_CONFIG_HOME` is unset
383
+
384
+ On Unix, the directory is created with mode `0700` and the file with mode `0600`.
385
+
386
+ Set up credentials with the auth commands:
387
+
388
+ ```bash
389
+ tmdb auth login # prompt, validate, and save a credential
390
+ tmdb auth login --force # replace existing credentials
391
+ printf '%s' "$TMDB_ACCESS_TOKEN" | tmdb auth login --token-stdin --force
392
+ tmdb auth status # source, masked token, and config path
393
+ tmdb auth logout # remove saved credentials
394
+ tmdb config test # verify credentials with a TMDB request
395
+ ```
396
+
397
+ `auth login` validates the credential with a TMDB request before saving it. `config test` performs the same check without changing the config. Use `--force` to replace existing credentials, or to save a config token while an environment variable is set and stdin is not a TTY.
398
+
399
+ Manage defaults with `config`:
400
+
401
+ ```bash
402
+ tmdb config set language zh-CN
403
+ tmdb config set region CN
404
+ tmdb config set token # prompts; secrets are not accepted as arguments
405
+ tmdb config unset language
406
+ tmdb config show # masked values and config path
407
+ tmdb config path
408
+ ```
409
+
410
+ Config keys are `token` or `accessToken`, `api-key` or `apiKey`, `language`, and `region`. Secrets entered with `config set token` or `config set api-key` are read from a prompt, never from the command line. `auth status` and `config show` mask stored secrets.
411
+
412
+ Use `--lang` to override the config language for a single command. `auth logout` removes saved credentials but leaves environment variables in place.
413
+
414
+ ### Commands
415
+
416
+ Every API command accepts `--lang`, `--format`, `--fields`, and `--compact`. Commands that return a paginated list also accept `--all` and `--max-items` with `--format ndjson`. Run `tmdb <command> --help` for the full option list.
417
+
418
+ | Command | Description | Command options |
419
+ | ------------------------------------------------ | --------------------------------------------------------------------------------- | ---------------------------- |
420
+ | `tmdb search <query>` | Search all media, movies, TV, or people. | `--type`, `--year`, `--page` |
421
+ | `tmdb movie list [category]` | List movies. Default `popular`; also `top-rated`, `now-playing`, `upcoming`. | `--page`, `--region` |
422
+ | `tmdb movie get <id>` | Movie details. | `--append` |
423
+ | `tmdb movie credits <id>` | Movie cast and crew. | `--limit` |
424
+ | `tmdb movie images <id>` | Movie image URLs. | `--image-language` |
425
+ | `tmdb movie videos <id>` | Movie videos and trailers. | none |
426
+ | `tmdb movie similar <id>` | Similar movies. | `--page`, `--region` |
427
+ | `tmdb movie recommendations <id>` | Recommended movies. | `--page`, `--region` |
428
+ | `tmdb tv list [category]` | List TV shows. Default `popular`; also `top-rated`, `airing-today`, `on-the-air`. | `--page` |
429
+ | `tmdb tv get <id>` | TV show details. | `--append` |
430
+ | `tmdb tv credits <id>` | TV cast and crew. | `--limit` |
431
+ | `tmdb tv images <id>` | TV image URLs. | `--image-language` |
432
+ | `tmdb tv videos <id>` | TV videos and trailers. | none |
433
+ | `tmdb tv similar <id>` | Similar TV shows. | `--page` |
434
+ | `tmdb tv recommendations <id>` | Recommended TV shows. | `--page` |
435
+ | `tmdb tv season <id> <season>` | TV season and episodes. Use `0` for specials. | none |
436
+ | `tmdb tv episode <id> <season> <episode>` | TV episode details. Use `0` for specials. | `--append` |
437
+ | `tmdb tv aggregate-credits <id>` | Aggregate cast and crew credits for a show. | `--limit` |
438
+ | `tmdb tv season-credits <id> <season>` | Season cast and crew credits. | `--limit` |
439
+ | `tmdb tv season-aggregate-credits <id> <season>` | Aggregate season cast and crew credits. | `--limit` |
440
+ | `tmdb tv season-images <id> <season>` | Season image URLs. | `--image-language` |
441
+ | `tmdb tv season-videos <id> <season>` | Season videos and trailers. | none |
442
+ | `tmdb tv episode-groups <id>` | Alternate episode groups for a show. | none |
443
+ | `tmdb tv episode-group <group-id>` | Episodes in an alternate group. | none |
444
+ | `tmdb person get <id>` | Person details. | none |
445
+ | `tmdb person credits <id>` | Combined cast and crew credits. | `--limit` |
446
+ | `tmdb trending [type]` | Trending media. Default `all`; also `movie`, `tv`, `person`. | `--window`, `--page` |
447
+ | `tmdb genres [type]` | Genres. Default `movie`; also `tv`. | none |
448
+
449
+ Credential and config commands:
450
+
451
+ | Command | Description | Command options |
452
+ | ------------------------------- | ------------------------------------------------------ | ----------------------------------- |
453
+ | `tmdb auth login` | Validate and save credentials. | `--token-stdin`, `--force` |
454
+ | `tmdb auth status` | Show credential source, masked token, and config path. | `--format`, `--fields`, `--compact` |
455
+ | `tmdb auth logout` | Remove saved credentials. | none |
456
+ | `tmdb config show` | Show config values with secrets masked. | `--format`, `--fields`, `--compact` |
457
+ | `tmdb config path` | Print the config file path. | none |
458
+ | `tmdb config test` | Verify credentials with an authenticated request. | `--format`, `--fields`, `--compact` |
459
+ | `tmdb config set <key> [value]` | Set `token`, `api-key`, `language`, or `region`. | none |
460
+ | `tmdb config unset <key>` | Remove a config value. | none |
461
+
462
+ ### Common options
463
+
464
+ | Option | Description |
465
+ | ------------------------------- | ----------------------------------------------------------------------------------- |
466
+ | `--format <text\|json\|ndjson>` | Output format. `json` prints the raw response, `ndjson` prints one record per line. |
467
+ | `--fields <list>` | Comma-separated top-level fields to keep. Requires `--format json` or `ndjson`. |
468
+ | `--json` | Alias for `--format json`. |
469
+ | `--compact` | Print JSON on a single line instead of pretty-printing it. |
470
+ | `--all` | Follow pagination to the last page. List commands only; requires `ndjson`. |
471
+ | `--max-items <number>` | Stop after this many items. List commands only; requires `ndjson`. |
472
+ | `--lang <code>` | Language for this command. Defaults to config `language`, then `en-US`. |
473
+ | `--page <number>` | Page number. Defaults to `1`. |
474
+ | `--region <code>` | Region for movie lists, similar, and recommendations. Defaults to config `region`. |
475
+ | `--limit <number>` | Maximum rows for credits. Defaults to `10`. |
476
+ | `--append <list>` | Comma-separated `append_to_response` values, such as `credits,videos`. |
477
+ | `--image-language <list>` | Comma-separated image language codes. |
478
+ | `--year <year>` | Movie release year or TV first-air year. Only with `--type movie` or `--type tv`. |
479
+ | `--type <value>` | Media selector for `search`: `all`, `movie`, `tv`, or `person`. |
480
+ | `--window <day\|week>` | Trending window. Defaults to `week`. |
481
+
482
+ `--version` prints the CLI version.
483
+
484
+ ### Output and exit codes
485
+
486
+ The default output is a compact summary for reading in a terminal. Use `--format json` for scripts; `--json` is a shorter alias:
487
+
488
+ ```bash
489
+ tmdb movie get 550 --format json | jq '.title, .release_date'
490
+ tmdb movie get 550 --json | jq '.title'
491
+ tmdb movie get 550 --format json --compact # single line, for line-oriented tools
492
+ ```
493
+
494
+ `--compact` only affects `json` output and is ignored for `text`. Combining `--json` with an explicit `--format text` or `--format ndjson` is a usage error.
495
+
496
+ #### NDJSON
497
+
498
+ `--format ndjson` (alias `jsonl`) prints one JSON object per line. Responses with a `results` array stream as three record kinds:
499
+
500
+ <!-- prettier-ignore -->
501
+ ```json
502
+ {"type":"meta","page":1,"total_pages":5,"total_results":100}
503
+ {"type":"item","index":0,"value":{"id":550,"title":"Fight Club"}}
504
+ {"type":"summary","count":20,"pages":1}
505
+ ```
506
+
507
+ `meta` is the response envelope without `results`, `index` is zero-based, and `summary` closes the stream with the item count and pages read. Every other response emits a single record:
508
+
509
+ <!-- prettier-ignore -->
510
+ ```json
511
+ {"type":"result","value":{"id":550,"title":"Fight Club"}}
512
+ ```
513
+
514
+ Because the stream is line-oriented, a missing `summary` line means the command failed; the reason is still on stderr as a single `{"type":"error",...}` record.
338
515
 
339
- | Getter | Status |
340
- | --- | --- |
341
- | `isUnauthorized` | 401 |
342
- | `isForbidden` | 403 |
343
- | `isNotFound` | 404 |
344
- | `isRateLimit` | 429 |
345
- | `isServerError` | 5xx |
516
+ #### Pagination
346
517
 
347
- All errors are also instances of the base class `TMDBError`; use that
348
- when you want a single catch-all.
518
+ List commands (`search`, `trending`, `movie list/similar/recommendations`, and `tv list/similar/recommendations`) accept `--all` to follow pagination from `--page` to the last page, and `--max-items` to stop early:
519
+
520
+ ```bash
521
+ tmdb trending movie --format ndjson --all --max-items 100
522
+ ```
523
+
524
+ `--max-items` caps the number of `item` records with or without `--all`. Both flags require `--format ndjson`, and `--all` may issue many requests: TMDB caps lists at 500 pages and the command stops at that limit.
525
+
526
+ #### Selecting fields
527
+
528
+ `--fields` keeps only the listed top-level fields. For detail commands it projects the response; for list commands it projects each entry in `results` while keeping the envelope:
529
+
530
+ ```bash
531
+ tmdb movie get 550 --format json --fields id,title,release_date
532
+ tmdb movie list --format ndjson --fields id,title | jq -c 'select(.type == "item")'
533
+ ```
534
+
535
+ Fields that a record does not have are omitted. If none of the requested fields exist, the command exits `2` and lists the available names. Nested paths are not supported, and `--fields` requires `--format json` or `--format ndjson`.
536
+
537
+ When JSON output is active, errors go to stderr as one JSON object and stdout stays empty, so a pipeline never receives a partial payload:
538
+
539
+ <!-- prettier-ignore -->
540
+ ```json
541
+ {"type":"error","kind":"not_found","message":"TMDB request failed (404): ...","exitCode":4,"status":404}
542
+ ```
543
+
544
+ | Field | Meaning |
545
+ | ------------ | ---------------------------------------------------------------------------- |
546
+ | `type` | Always `error`. |
547
+ | `kind` | `usage`, `auth`, `not_found`, `rate_limit`, `network`, `api`, or `internal`. |
548
+ | `message` | Human-readable description. |
549
+ | `exitCode` | Process exit code, matching the table below. |
550
+ | `status` | HTTP status, when the failure came from a TMDB response. |
551
+ | `requestId` | TMDB `x-request-id`, when the response included one. |
552
+ | `retryAfter` | Seconds to wait before retrying, for rate limit errors. |
553
+
554
+ Exit codes:
555
+
556
+ | Code | Meaning |
557
+ | ---- | -------------------------------------------------------------- |
558
+ | `0` | Success. |
559
+ | `1` | Unexpected error, network failure, or non-auth TMDB response. |
560
+ | `2` | Invalid command usage or input. |
561
+ | `3` | Missing or rejected credentials, or a config read/write error. |
562
+ | `4` | TMDB returned `404 Not Found`. |
563
+ | `5` | TMDB rate limit (`429 Too Many Requests`). |
564
+
565
+ ### Examples
566
+
567
+ ```bash
568
+ # Search movies released in 2010
569
+ tmdb search "Inception" --type movie --year 2010
570
+
571
+ # Movie details with credits and videos
572
+ tmdb movie get 550 --append credits,videos
573
+
574
+ # Trending movies today, raw JSON
575
+ tmdb trending movie --window day --json | jq '.results[].title'
576
+
577
+ # Season 1 episodes for a TV show
578
+ tmdb tv season 1399 1 --json | jq '.episodes[].name'
579
+
580
+ # Episode details (season 0 = specials)
581
+ tmdb tv episode 1399 1 1
582
+
583
+ # Alternate episode groups
584
+ tmdb tv episode-groups 1399
585
+
586
+ # Save defaults for later commands
587
+ tmdb config set language zh-CN
588
+ tmdb config set region CN
589
+ ```
349
590
 
350
591
  ## Development
351
592
 
593
+ Requires [Bun](https://bun.sh) 1.4.4+. The published package still runs on Node.js 18+.
594
+
595
+ ```bash
596
+ bun install
597
+ bun run format
598
+ bun run typecheck
599
+ bun run test:coverage
600
+ bun run build
601
+ ```
602
+
603
+ Run the integration suite with a real token. It exercises every SDK resource, the optional helpers, and the CLI against the live API:
604
+
605
+ ```bash
606
+ TMDB_ACCESS_TOKEN=your_access_token_here bun run test:integration
607
+ ```
608
+
609
+ Set `TMDB_API_ENDPOINT` to run the SDK suites against a compatible mirror. The `Integration` GitHub workflow runs the same suite nightly, on manual dispatch, and on pull requests that touch the SDK, CLI, or integration tests. Add a `TMDB_ACCESS_TOKEN` repository secret to enable it, and an optional `TMDB_API_KEY` secret to also cover legacy v3 key auth; fork and Dependabot pull requests skip the live job because repository secrets are not available to them.
610
+
611
+ Build and run the CLI from source:
612
+
352
613
  ```bash
353
- npm install
354
- npm run typecheck
355
- npm run test:coverage
356
- npm run build
614
+ bun run build:cli
615
+ bun dist/cli.js --help
357
616
  ```
358
617
 
359
618
  ## References
360
619
 
361
620
  - [TMDB authentication](https://developer.themoviedb.org/docs/authentication-application)
362
621
  - [TMDB API reference](https://developer.themoviedb.org/reference/intro/getting-started)
363
- - [tsdown output formats](https://tsdown.dev/options/output-format)
622
+ - [Bun bundler docs](https://bun.com/docs/bundler)