Skip to content
← Public packages

@kentcdodds/audible

Personal Audible library app — browser re-auth, library, wishlist, NAS archive stub.

src/client.ts

177 lines · 4.8 KB · TypeScript
import {
	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