Skip to content
← Public packages

@kentcdodds/audible

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

src/library.ts

160 lines · 4.8 KB · TypeScript
import { createClient } from './client.ts'
import { classifyArchiveEligibility } from './archive-eligibility.ts'
import type { SlimLibraryItem } from './types.ts'

const DEFAULT_RESPONSE_GROUPS = [
	'product_desc',
	'product_attrs',
	'contributors',
	'media',
	'series',
	'percent_complete',
	'is_finished',
	'product_details',
	// Required for reliable is_ayce / benefit_id on library *list* (Plus catalog).
	'customer_rights',
].join(',')

function pickCoverUrl(product: Record<string, unknown>): string | undefined {
	const images = product.product_images
	if (images && typeof images === 'object' && !Array.isArray(images)) {
		const map = images as Record<string, unknown>
		for (const key of ['500', '300', '200', '100', '60']) {
			const v = map[key]
			if (typeof v === 'string' && v.startsWith('http')) return v
		}
		for (const v of Object.values(map)) {
			if (typeof v === 'string' && v.startsWith('http')) return v
		}
	}
	if (typeof product.cover_url === 'string') return product.cover_url
	return undefined
}

function slimItem(raw: Record<string, unknown>): SlimLibraryItem {
	const product = (raw.item ?? raw) as Record<string, unknown>
	const authors = Array.isArray(product.authors)
		? (product.authors as Array<{ name?: string }>)
				.map((a) => a.name)
				.filter((n): n is string => Boolean(n))
		: undefined
	const narrators = Array.isArray(product.narrators)
		? (product.narrators as Array<{ name?: string }>)
				.map((a) => a.name)
				.filter((n): n is string => Boolean(n))
		: undefined
	const series = Array.isArray(product.series)
		? (product.series as Array<{ title?: string; sequence?: string }>).map(
				(s) => ({ title: s.title, sequence: s.sequence }),
			)
		: undefined
	const eligibility = classifyArchiveEligibility(product)
	const benefitId =
		typeof product.benefit_id === 'string'
			? product.benefit_id
			: product.benefit_id === null
				? null
				: undefined
	return {
		asin: String(product.asin ?? ''),
		title:
			typeof product.title === 'string'
				? product.title
				: typeof product.raw_title === 'string'
					? product.raw_title
					: undefined,
		authors,
		narrators,
		purchaseDate:
			typeof product.purchase_date === 'string'
				? product.purchase_date
				: undefined,
		runtimeLengthMin:
			typeof product.runtime_length_min === 'number'
				? product.runtime_length_min
				: undefined,
		isFinished:
			typeof product.is_finished === 'boolean' ? product.is_finished : undefined,
		percentComplete:
			typeof product.percent_complete === 'number'
				? product.percent_complete
				: undefined,
		series,
		coverUrl: pickCoverUrl(product),
		archiveEligibility: eligibility.archiveEligibility,
		archiveHint: eligibility.archiveHint,
		isAyce: product.is_ayce === true,
		benefitId,
		contentType:
			typeof product.content_type === 'string' ? product.content_type : undefined,
		contentDeliveryType:
			typeof product.content_delivery_type === 'string'
				? product.content_delivery_type
				: undefined,
		formatType:
			typeof product.format_type === 'string' ? product.format_type : undefined,
	}
}

/**
 * List or search titles in the signed-in Audible library (GET /1.0/library).
 * Use for personal archive inventory of owned titles.
 *
 * @param input - Optional search filters, pagination, and slim vs raw shape
 * @returns Library page with slim items by default
 *
 * @example
 * import listLibrary from 'kody:@kentcdodds/audible/library'
 *
 * const page = await listLibrary({ title: 'Project Hail Mary', numResults: 20 })
 */
export default async function listLibrary(input: {
	title?: string
	author?: string
	numResults?: number
	page?: number
	purchasedAfter?: string
	sortBy?: string
	status?: 'Active' | 'Revoked'
	responseGroups?: string
	/** When true, return the raw Audible payload instead of slim items. */
	raw?: boolean
	locale?: string
} = {}) {
	const client = await createClient({ locale: input.locale })
	const numResults = Math.min(Math.max(input.numResults ?? 50, 1), 1000)
	const payload = await client.request<{
		items?: Array<Record<string, unknown>>
		total_results?: number
	}>({
		path: '/1.0/library',
		query: {
			num_results: numResults,
			page: input.page ?? 1,
			title: input.title,
			author: input.author,
			purchased_after: input.purchasedAfter,
			sort_by: input.sortBy ?? '-PurchaseDate',
			status: input.status ?? 'Active',
			response_groups: input.responseGroups ?? DEFAULT_RESPONSE_GROUPS,
		},
	})

	if (input.raw) {
		return {
			locale: client.locale.countryCode,
			apiHost: client.locale.apiHost,
			totalResults: payload.total_results ?? payload.items?.length ?? 0,
			items: payload.items ?? [],
		}
	}

	const items = (payload.items ?? []).map(slimItem).filter((i) => i.asin)
	return {
		locale: client.locale.countryCode,
		apiHost: client.locale.apiHost,
		totalResults: payload.total_results ?? items.length,
		count: items.length,
		items,
	}
}