← Public packages
@kentcdodds/audible
Personal Audible library app — browser re-auth, library, wishlist, NAS archive stub.
src/client.ts
177 lines · 4.8 KB · TypeScriptimport {
AUDIBLE_AUTH_SECRET,
audibleAuthSetupUrl,
fromAuthFile,
loadAuthFromSecret,
} from './auth.ts'
import { resolveLocale, type LocaleInfo } from './locales.ts'
import { signRequest } from './sign.ts'
import type { AudibleAuth } from './types.ts'
import { refreshAccessToken } from './register.ts'
import { accessTokenFreshness } from './auth.ts'
import { persistRefreshedAuth } from './persist-auth.ts'
export { fromAuthFile, signRequest, resolveLocale, AUDIBLE_AUTH_SECRET }
export type { AudibleAuth, LocaleInfo }
export class AudibleApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown, message?: string) {
super(message ?? `Audible API HTTP ${status}`)
this.name = 'AudibleApiError'
this.status = status
this.body = body
}
}
export type AudibleClient = {
auth: AudibleAuth
locale: LocaleInfo
request: <T = unknown>(input: {
method?: string
path: string
query?: Record<string, string | number | boolean | undefined | null>
body?: unknown
headers?: Record<string, string>
}) => Promise<T>
}
function buildPath(
path: string,
query?: Record<string, string | number | boolean | undefined | null>,
): string {
const base = path.startsWith('/') ? path : `/${path}`
if (!query) return base
const params = new URLSearchParams()
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue
params.set(key, String(value))
}
const qs = params.toString()
return qs ? `${base}?${qs}` : base
}
/**
* Create a signed Audible API client from auth JSON or the mounted `audibleAuth` secret.
*
* Prefer calling package exports (`./library`, `./wishlist`, …). Use this when you
* need a one-off path against the unofficial Audible API with ADP request signing.
*
* @param input - Optional auth object / JSON string, or `{ fromSecret: true }` (default)
* @returns Signed client bound to the auth locale marketplace
*
* @example
* import { createClient } from 'kody:@kentcdodds/audible/client'
*
* const client = await createClient()
* const library = await client.request({
* path: '/1.0/library',
* query: { num_results: 10, response_groups: 'product_desc,contributors' },
* })
*/
export async function createClient(input?: {
auth?: AudibleAuth | string | Record<string, unknown>
fromSecret?: boolean
locale?: string
}): Promise<AudibleClient> {
let auth: AudibleAuth
if (input?.auth) {
auth = fromAuthFile(input.auth)
} else {
const loaded = await loadAuthFromSecret()
if (!loaded.ok) {
throw new Error(`${loaded.reason} Setup: ${loaded.setupUrl}`)
}
auth = loaded.auth
}
const locale = resolveLocale(input?.locale ?? auth.locale ?? 'us')
async function maybeRefresh(): Promise<void> {
if (!auth.refresh_token) return
const freshness = accessTokenFreshness(auth)
const needsRefresh =
!auth.access_token ||
freshness.expired === true ||
(freshness.secondsRemaining != null && freshness.secondsRemaining <= 120)
if (!needsRefresh) return
try {
const refreshed = await refreshAccessToken({
refreshToken: auth.refresh_token,
domain: locale.domain,
withUsername: false,
})
auth.access_token = refreshed.access_token
auth.expires = refreshed.expires
void persistRefreshedAuth(auth)
} catch {
// ADP signing still works; bearer refresh failure is non-fatal for library GETs
}
}
async function request<T = unknown>(req: {
method?: string
path: string
query?: Record<string, string | number | boolean | undefined | null>
body?: unknown
headers?: Record<string, string>
}): Promise<T> {
await maybeRefresh()
const method = (req.method ?? 'GET').toUpperCase()
const pathWithQuery = buildPath(req.path, req.query)
const bodyText =
req.body === undefined || req.body === null
? ''
: typeof req.body === 'string'
? req.body
: JSON.stringify(req.body)
const signed = await signRequest({
method,
path: pathWithQuery,
body: bodyText,
adpToken: auth.adp_token,
devicePrivateKey: auth.device_private_key,
})
const headers: Record<string, string> = {
Accept: 'application/json',
...signed,
...(req.headers ?? {}),
}
if (bodyText && !headers['Content-Type'] && !headers['content-type']) {
headers['Content-Type'] = 'application/json'
}
const url = `https://${locale.apiHost}${pathWithQuery}`
const response = await fetch(url, {
method,
headers,
body: bodyText || undefined,
})
const text = await response.text()
let parsed: unknown = text
if (text) {
try {
parsed = JSON.parse(text)
} catch {
parsed = text
}
} else {
parsed = null
}
if (!response.ok) {
throw new AudibleApiError(
response.status,
parsed,
`Audible API ${method} ${pathWithQuery} → HTTP ${response.status}`,
)
}
return parsed as T
}
return { auth, locale, request }
}
export default createClient