Skip to content
← Public packages

@kentcdodds/audible

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

src/archive-to-nas.ts

247 lines · 7.3 KB · TypeScript
import downloadLicense from './download-license.ts'
import getLibraryItem from './library-item.ts'
import { mapChapters, titleFromLibraryPayload } from './chapters.ts'
import { getHomeAudiobookMcp } from './home-mcp.ts'
import { resolveNasExists } from './nas-filename-candidates.ts'
import { writeThroughNasStatus } from './nas-status-cache.ts'
import {
	ArchiveEligibilityError,
	assertArchiveDownloadable,
	productFromLibraryPayload,
} from './archive-eligibility.ts'
import {
	isAudiobookAlreadyExistsImportResult,
	importErrorFilename,
} from './import-already-exists.ts'

const SECRET_RESULT_KEYS = new Set([
	'key',
	'iv',
	'voucher',
	'aaxcBase64',
	'coverBase64',
	'license_response',
	'encryptedVoucher',
	'audible_key',
	'audible_iv',
])

export type ArchiveToNasResult = {
	ok: boolean
	asin: string
	title: string
	filename: string
	libraryPath?: string
	skipped?: boolean
	reason?: string
	quality?: 'High' | 'Normal'
	overwrite?: boolean
	chapterCount?: number
	drmType?: string
	hasContentUrl?: boolean
	hasKey?: boolean
	hasIv?: boolean
	importResult?: Record<string, unknown>
	message: string
	archiveEligibility?: string
}


function sanitizeImportResult(result: unknown): Record<string, unknown> | undefined {
	if (!result || typeof result !== 'object' || Array.isArray(result)) return undefined
	const out: Record<string, unknown> = {}
	for (const [key, value] of Object.entries(result as Record<string, unknown>)) {
		const lower = key.toLowerCase()
		if (SECRET_RESULT_KEYS.has(key)) continue
		if (lower === 'key' || lower === 'iv' || lower.endsWith('_key') || lower.endsWith('_iv')) {
			continue
		}
		out[key] = value
	}
	return out
}

async function resolveLibraryProduct(
	asin: string,
	passedTitle?: string,
): Promise<{ title: string; product: Record<string, unknown> }> {
	try {
		const item = await getLibraryItem({ asin })
		const product = productFromLibraryPayload(item.item)
		const title =
			passedTitle?.trim() ||
			titleFromLibraryPayload(item.item) ||
			titleFromLibraryPayload(product) ||
			undefined
		if (!title) {
			throw new Error(
				`Library item ${asin} has no title. Pass title explicitly to archiveToNas.`,
			)
		}
		return { title, product }
	} catch (error) {
		if (error instanceof ArchiveEligibilityError) throw error
		if (passedTitle?.trim()) {
			// Title override without a library row — still try license (purchased path).
			return { title: passedTitle.trim(), product: { asin } }
		}
		throw new Error(
			`Could not resolve a title for ASIN ${asin} (needed for the NAS filename). ${String((error as Error)?.message ?? error)}`,
		)
	}
}

/**
 * Archive one owned title to the household audiobook library on the NAS.
 * This package talks to Audible only; home MCP converts AAXC → M4B and writes disk.
 *
 * Plus catalog / unsupported formats throw {@link ArchiveEligibilityError}
 * (friendly, non-retryable) before licenserequest — not a generic license denial.
 *
 * @param input - ASIN plus optional quality, overwrite, and title
 * @returns Safe summary (no voucher key/iv values)
 *
 * @example
 * import archiveToNas from 'kody:@kentcdodds/audible/archive-to-nas'
 *
 * const result = await archiveToNas({ asin: 'B08G9PRS1K' })
 * // { ok: true, filename: 'Title.m4b', skipped?: true }
 */
export default async function archiveToNas(input: {
	asin: string
	/** Optional title override when library-item is unavailable. */
	title?: string
	quality?: 'High' | 'Normal'
	overwrite?: boolean
	/** Ignored — home MCP names files as flat Title.m4b. */
	destination?: string
}): Promise<ArchiveToNasResult> {
	const asin = input?.asin?.trim()
	if (!asin) throw new Error('asin is required.')

	const home = getHomeAudiobookMcp()
	const pathInfo = await home.audiobook_library_path()
	if (!pathInfo?.writable) {
		throw new Error(
			`Audiobook library path is not writable (exists=${Boolean(pathInfo?.exists)}, writable=${Boolean(pathInfo?.writable)}, path=${pathInfo?.libraryPath ?? 'unknown'}). Mount /media/audiobooks on the home connector and retry.`,
		)
	}

	const { title, product } = await resolveLibraryProduct(asin, input.title)
	// Canonical + OpenAudible colon→`- ` candidates — skip if any exists on disk.
	const nasCheck = await resolveNasExists(home, title)
	const filename = nasCheck.canonicalFilename
	if (nasCheck.onNas && !input.overwrite) {
		const matched = nasCheck.filename
		await writeThroughNasStatus({ asin, title, filename: matched, onNas: true })
		return {
			ok: true,
			skipped: true,
			asin,
			title,
			filename: matched,
			libraryPath: pathInfo.libraryPath,
			reason: 'already_exists',
			quality: input.quality ?? 'High',
			overwrite: false,
			message: `${title} is already in the audiobook library as ${matched}. Pass overwrite: true to replace it.`,
		}
	}

	// Soft-block Plus / Show before Adrm licenserequest (avoids license-denial retries).
	assertArchiveDownloadable({ asin, title, product })

	const license = await downloadLicense({
		asin,
		quality: input.quality ?? 'High',
	})
	const key = license.voucher?.key
	const iv = license.voucher?.iv
	const hasContentUrl = Boolean(license.contentUrl)
	const hasKey = Boolean(key)
	const hasIv = Boolean(iv)
	const drmType = license.drmType

	if (!hasContentUrl || !hasKey || !hasIv) {
		throw new Error(
			`License for ${asin} is not Adrm AAXC (drmType=${drmType ?? 'unknown'}, hasContentUrl=${hasContentUrl}, hasKey=${hasKey}, hasIv=${hasIv}). archiveToNas needs contentUrl + key/iv from download-license. Re-auth the Audible device if Adrm is denied.`,
		)
	}

	const chapters = mapChapters(license.chapterInfo)
	const importResult = await home.audiobook_import_aaxc({
		aaxcUrl: license.contentUrl,
		key,
		iv,
		title,
		chapters,
		overwrite: Boolean(input.overwrite),
	})

	const savedFilename =
		typeof importResult?.filename === 'string' ? importResult.filename : filename
	const alreadyExists = isAudiobookAlreadyExistsImportResult(importResult)
	if (alreadyExists) {
		const matched = importErrorFilename(importResult) || savedFilename
		await writeThroughNasStatus({ asin, title, filename: matched, onNas: true })
		return {
			ok: true,
			skipped: true,
			asin,
			title,
			filename: matched,
			libraryPath:
				typeof importResult?.libraryPath === 'string'
					? importResult.libraryPath
					: pathInfo.libraryPath,
			reason: 'already_exists',
			quality: input.quality ?? 'High',
			overwrite: Boolean(input.overwrite),
			chapterCount: chapters?.length,
			drmType,
			hasContentUrl,
			hasKey,
			hasIv,
			importResult: sanitizeImportResult(importResult),
			archiveEligibility: 'downloadable',
			message: `${title} is already in the audiobook library as ${matched}. Pass overwrite: true to replace it.`,
		}
	}

	const ok = importResult?.ok !== false
	if (ok) {
		await writeThroughNasStatus({
			asin,
			title,
			filename: savedFilename,
			onNas: true,
		})
	}

	return {
		ok,
		asin,
		title,
		filename: savedFilename,
		libraryPath:
			typeof importResult?.libraryPath === 'string'
				? importResult.libraryPath
				: pathInfo.libraryPath,
		skipped: false,
		quality: input.quality ?? 'High',
		overwrite: Boolean(input.overwrite),
		chapterCount: chapters?.length,
		drmType,
		hasContentUrl,
		hasKey,
		hasIv,
		importResult: sanitizeImportResult(importResult),
		archiveEligibility: 'downloadable',
		message:
			ok
				? `Saved ${title} as ${savedFilename}.`
				: `Home convert did not finish for ${title}. See importResult for details.`,
	}
}

export { ArchiveEligibilityError }