← Public packages
@kentcdodds/audible
Personal Audible library app — browser re-auth, library, wishlist, NAS archive stub.
src/archive-eligibility.ts
161 lines · 4.8 KB · TypeScript/**
* Classify whether a library title can be archived via Adrm AAXC → NAS.
*
* Audible list/item payloads need `customer_rights` in response_groups for
* reliable `is_ayce` / `benefit_id` (without it, Plus titles often look purchased).
*/
export type ArchiveEligibility =
| 'downloadable'
| 'plus_stream'
| 'unsupported_format'
export type ArchiveEligibilityInfo = {
archiveEligibility: ArchiveEligibility
/** Quiet UI / agent hint — not an error path. */
archiveHint: string
/** True only for purchased single-part audiobook downloads. */
downloadable: boolean
/** Do not retry archive-to-nas / licenserequest for this title. */
retryable: boolean
}
export class ArchiveEligibilityError extends Error {
readonly asin: string
readonly archiveEligibility: Exclude<ArchiveEligibility, 'downloadable'>
readonly archiveHint: string
readonly retryable = false as const
readonly code = 'ARCHIVE_NOT_ELIGIBLE' as const
constructor(input: {
asin: string
title?: string
archiveEligibility: Exclude<ArchiveEligibility, 'downloadable'>
archiveHint: string
}) {
const label = input.title?.trim() || input.asin
const message =
input.archiveEligibility === 'plus_stream'
? `${label} is Audible Plus (stream only) — Adrm download is not available. Do not retry archive-to-nas for this ASIN.`
: `${label} is not an audiobook download. Do not retry archive-to-nas for this ASIN.`
super(message)
this.name = 'ArchiveEligibilityError'
this.asin = input.asin
this.archiveEligibility = input.archiveEligibility
this.archiveHint = input.archiveHint
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
}
/** Unwrap library list/item wrappers to the product record. */
export function productFromLibraryPayload(raw: unknown): Record<string, unknown> {
const top = asRecord(raw)
if (!top) return {}
const nested = asRecord(top.item)
return nested ?? top
}
function isPlusCatalog(product: Record<string, unknown>): boolean {
if (product.is_ayce === true) return true
const benefitId = product.benefit_id
if (typeof benefitId === 'string' && benefitId.toUpperCase() === 'AYCL') {
return true
}
const benefitType = product.benefit_type
if (typeof benefitType === 'string' && benefitType.toUpperCase() === 'AYCL') {
return true
}
return false
}
function codecCount(product: Record<string, unknown>): number {
const codecs = product.available_codecs
return Array.isArray(codecs) ? codecs.length : 0
}
function runtimeMinutes(product: Record<string, unknown>): number | null {
if (typeof product.runtime_length_min === 'number') {
return product.runtime_length_min
}
if (typeof product.runtime_length_sec === 'number') {
return Math.floor(product.runtime_length_sec / 60)
}
return null
}
function isUnsupportedFormat(product: Record<string, unknown>): boolean {
const contentType =
typeof product.content_type === 'string' ? product.content_type : ''
const formatType =
typeof product.format_type === 'string' ? product.format_type : ''
const delivery =
typeof product.content_delivery_type === 'string'
? product.content_delivery_type
: ''
if (contentType === 'Show') return true
if (formatType === 'original_recording') return true
if (delivery === 'Periodical' && contentType !== 'Product') return true
const runtime = runtimeMinutes(product)
const codecs = codecCount(product)
// Odd catalog rows: zero runtime + no codecs (licenserequest → 400).
if (runtime === 0 && codecs === 0) return true
if (product.has_children === true && product.is_listenable === false) {
return true
}
return false
}
/**
* Map an Audible library product (or slim fields) to archive eligibility.
*/
export function classifyArchiveEligibility(
raw: unknown,
): ArchiveEligibilityInfo {
const product = productFromLibraryPayload(raw)
if (isUnsupportedFormat(product)) {
return {
archiveEligibility: 'unsupported_format',
archiveHint: 'Not an audiobook download',
downloadable: false,
retryable: false,
}
}
if (isPlusCatalog(product)) {
return {
archiveEligibility: 'plus_stream',
archiveHint: 'Plus · stream only',
downloadable: false,
retryable: false,
}
}
return {
archiveEligibility: 'downloadable',
archiveHint: 'Purchased · downloadable',
downloadable: true,
retryable: true,
}
}
export function assertArchiveDownloadable(input: {
asin: string
title?: string
product: unknown
}): ArchiveEligibilityInfo {
const info = classifyArchiveEligibility(input.product)
if (info.archiveEligibility === 'downloadable') return info
throw new ArchiveEligibilityError({
asin: input.asin,
title: input.title,
archiveEligibility: info.archiveEligibility,
archiveHint: info.archiveHint,
})
}