Skip to content
← Public packages

@kentcdodds/audible

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

src/download-license.ts

159 lines · 5.6 KB · TypeScript
import { createClient } from './client.ts'
import { decryptVoucher } from './voucher.ts'
import type { LicenseMetadata } from './types.ts'

function asString(value: unknown): string | undefined {
	return typeof value === 'string' && value.trim() ? value.trim() : undefined
}

function pickKeyIv(source: unknown): { key?: string; iv?: string; rules?: unknown } {
	if (!source || typeof source !== 'object') return {}
	const rec = source as Record<string, unknown>
	const nested =
		rec.license_response && typeof rec.license_response === 'object'
			? (rec.license_response as Record<string, unknown>)
			: null
	return {
		key:
			asString(rec.key) ??
			asString(rec.audible_key) ??
			asString(nested?.key) ??
			asString(nested?.audible_key),
		iv:
			asString(rec.iv) ??
			asString(rec.audible_iv) ??
			asString(nested?.iv) ??
			asString(nested?.audible_iv),
		rules: rec.rules,
	}
}

/**
 * Request an AAXC content license for one owned ASIN (POST licenserequest).
 * Asks for Adrm (OpenAudible-style) so the response includes content_url plus
 * a voucher with key/iv for home MCP convert. Does not write files or run ffmpeg.
 *
 * @param input - ASIN and optional quality / response groups
 * @returns License metadata safe to hand to a home convert connector
 *
 * @example
 * import downloadLicense from 'kody:@kentcdodds/audible/download-license'
 *
 * const license = await downloadLicense({ asin: 'B08G9PRS1K', quality: 'High' })
 * // hand license.contentUrl + license.voucher to home.audiobook_import_aaxc
 */
export default async function downloadLicense(input: {
	asin: string
	quality?: 'High' | 'Normal'
	consumptionType?: 'Download' | 'Offline' | 'Streaming'
	responseGroups?: string
	locale?: string
	/** When true, leave license_response encrypted and skip voucher decrypt. */
	skipVoucherDecrypt?: boolean
}): Promise<LicenseMetadata & { locale: string; apiHost: string }> {
	const asin = input.asin?.trim()
	if (!asin) throw new Error('asin is required.')

	const client = await createClient({ locale: input.locale })
	// OpenAudible / audible-cli Adrm download — do not advertise Widevine or
	// the API returns a streaming license with no AAXC content_url / key / iv.
	const body = {
		drm_type: 'Adrm',
		supported_drm_types: ['Adrm'],
		quality: input.quality ?? 'High',
		consumption_type: input.consumptionType ?? 'Download',
		response_groups:
			input.responseGroups ??
			'chapter_info,content_reference,last_position_heard,pdf_url',
		chapter_titles_type: 'Flat',
	}

	const response = await client.request<{
		content_license?: Record<string, unknown>
		message?: string
	}>({
		method: 'POST',
		path: `/1.0/content/${encodeURIComponent(asin)}/licenserequest`,
		body,
	})

	const license = response.content_license ?? {}
	const statusCode =
		typeof license.status_code === 'string' ? license.status_code : undefined
	if (statusCode && statusCode.toLowerCase() === 'denied') {
		const denial = typeof license.message === 'string' ? license.message : 'no message'
		throw new Error(
			`Audible denied the Adrm license for ${asin}: ${denial}. Re-auth the device if it was invalidated.`,
		)
	}
	const metadata = (license.content_metadata ?? {}) as Record<string, unknown>
	const contentUrlObj = (metadata.content_url ?? {}) as Record<string, unknown>
	const contentUrl =
		typeof contentUrlObj.offline_url === 'string'
			? contentUrlObj.offline_url
			: typeof metadata.content_url === 'string'
				? metadata.content_url
				: undefined

	const licenseResponse = license.license_response
	const encrypted = typeof licenseResponse === 'string' ? licenseResponse : undefined

	let voucher: LicenseMetadata['voucher']
	if (encrypted && !input.skipVoucherDecrypt) {
		try {
			const decrypted = await decryptVoucher({
				auth: client.auth,
				asin: String(license.asin ?? asin),
				encryptedVoucher: encrypted,
			})
			const picked = pickKeyIv(decrypted.raw ?? decrypted)
			voucher = {
				key: picked.key ?? decrypted.key,
				iv: picked.iv ?? decrypted.iv,
				rules: picked.rules ?? decrypted.rules,
			}
		} catch (error) {
			voucher = {
				rules: {
					decryptError: String((error as Error)?.message ?? error),
					encryptedPresent: true,
				},
			}
		}
	} else if (encrypted) {
		voucher = { rules: { encryptedPresent: true, skippedDecrypt: true } }
	} else if (licenseResponse && typeof licenseResponse === 'object') {
		voucher = pickKeyIv(licenseResponse)
	}

	const drmType = typeof license.drm_type === 'string' ? license.drm_type : undefined
	const hasKey = Boolean(voucher?.key)
	const hasIv = Boolean(voucher?.iv)
	const isAdrm = (drmType ?? '').toLowerCase() === 'adrm'

	const format =
		typeof metadata.content_reference === 'object' &&
		metadata.content_reference &&
		typeof (metadata.content_reference as { content_format?: string }).content_format ===
			'string'
			? String(
					(metadata.content_reference as { content_format?: string }).content_format,
				)
			: 'aaxc'

	return {
		asin: String(license.asin ?? asin),
		status: typeof license.status_code === 'string' ? license.status_code : undefined,
		contentUrl,
		drmType,
		format,
		voucher,
		chapterInfo: license.chapter_info ?? metadata.chapter_info,
		contentReference: metadata.content_reference,
		message: isAdrm && contentUrl && hasKey && hasIv
			? 'Adrm AAXC license ready — hand contentUrl + voucher to home.audiobook_import_aaxc. This export does not download bytes or write to NAS.'
			: `License is ${drmType ?? 'unknown'} (hasContentUrl=${Boolean(contentUrl)}, hasKey=${hasKey}, hasIv=${hasIv}). Archive needs Adrm with contentUrl + key/iv — retry download-license or check device auth.`,
		locale: client.locale.countryCode,
		apiHost: client.locale.apiHost,
	}
}