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