← 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 · TypeScriptimport { 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,
}
}