Skip to content
← 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,
	})
}