Skip to content
← Public packages

@kentcdodds/audible

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

AGENTS.md

266 lines · 12.9 KB · Markdown

@kentcdodds/audible — agent notes

Human setup and intent live in README.md. Refer to secret audibleAuth by name only — never paste ADP tokens, private keys, access tokens, auth JSON, or maplanding URLs that contain codes into chat, logs, HTML success pages, or README examples.

Auth / secrets

StorePurpose
packageStorage key audible-auth-v1Source of truth for ADP signing. audible-cli-compatible JSON (adp_token, device_private_key, optional bearer + refresh_token, …). Never return this from exports.
Secret audibleAuth (optional)Opaque mount / host allowlist naming via best-effort secretSet. After opaque-secrets, mounts are {{secret:…}} placeholders and cannot be JSON.parsed for signing.

kody.secretMounts.audibleAuth may still be declared (package scope). Do not treat a successful mount as readable auth JSON.

Hosts to approve: api.audible.* and api.amazon.* for supported locales (src/locales.ts → AUTH_RELATED_HOSTS).

Re-auth playbook
  1. Open hosted app Connect tab (or ./start-auth with { locale: 'us' }).
  2. Kent signs in on Amazon; paste final maplanding URL into the app or ./complete-auth { redirectUrl, authSessionId }.
  3. Confirm with ./auth-status → signingReady: true (reads package storage).
  4. After the opaque-secrets platform change, one Connect re-auth is required (opaque secrets cannot be migrated). After that, re-auth only when the virtual device is invalidated or audible-auth-v1 is cleared — client refreshes access tokens from refresh_token and rewrites package storage.

PKCE verifier + locale + serial live in packageStorage() under auth-session:{id} for ~30 minutes while login is in flight.

Import paths

ExportImport
overviewkody:@kentcdodds/audible
clientkody:@kentcdodds/audible/client
librarykody:@kentcdodds/audible/library
library-itemkody:@kentcdodds/audible/library-item
wishlistkody:@kentcdodds/audible/wishlist
auth-statuskody:@kentcdodds/audible/auth-status
start-authkody:@kentcdodds/audible/start-auth
complete-authkody:@kentcdodds/audible/complete-auth
download-licensekody:@kentcdodds/audible/download-license
archive-to-naskody:@kentcdodds/audible/archive-to-nas
library-archive-statuskody:@kentcdodds/audible/library-archive-status
archive-remainingkody:@kentcdodds/audible/archive-remaining
nas-status-write-throughkody:@kentcdodds/audible/nas-status-write-through

App entry: package.json → kody.app.entry → ./src/app.ts. Build in-app links with packageContext.hostedUrl / appBasePath (see src/app/urls.ts).

Smoke tests

Auth readiness:

import authStatus from 'kody:@kentcdodds/audible/auth-status'

export default async function main() {
	return await authStatus()
}

Start auth (does not open a browser by itself):

import startAuth from 'kody:@kentcdodds/audible/start-auth'

export default async function main() {
	const started = await startAuth({ locale: 'us' })
	return {
		authSessionId: started.authSessionId,
		hasLoginUrl: Boolean(started.loginUrl),
		locale: started.locale,
	}
}

Eligibility flags (no download):

import listLibrary from 'kody:@kentcdodds/audible/library'

export default async function main() {
	const page = await listLibrary({ title: 'Swindle', numResults: 5 })
	return page.items.map((i) => ({
		asin: i.asin,
		title: i.title,
		archiveEligibility: i.archiveEligibility,
		archiveHint: i.archiveHint,
	}))
	// expect Swindle B002V59RLK → plus_stream / "Plus · stream only"
}

Archive skip (prefer a title already on the NAS — do not download a huge book just to test):

import archiveToNas from 'kody:@kentcdodds/audible/archive-to-nas'

export default async function main() {
	return await archiveToNas({ asin: 'B002UZKI96', title: 'Hatchet' })
	// expect { ok: true, skipped: true, reason: 'already_exists', filename: 'Hatchet.m4b' }
}

Library NAS status (batch; home has no list-all; SWR-cached):

import libraryArchiveStatus from 'kody:@kentcdodds/audible/library-archive-status'

export default async function main() {
	const cold = await libraryArchiveStatus({
		items: [
			{ asin: 'B002UZKI96', title: 'Hatchet' },
			{ title: 'ZZZ Not A Real Book 99999' },
		],
		forceRefresh: true,
	})
	const warm = await libraryArchiveStatus({
		items: [{ asin: 'B002UZKI96', title: 'Hatchet' }],
	})
	return {
		coldOnNas: cold.items.find((i) => i.title === 'Hatchet')?.onNas,
		warmCache: warm.cache,
		warmHit: warm.items[0]?.cacheHit,
	}
	// expect warm.cache.hits >= 1 within soft TTL (~60s)
}

Colon titles that OpenAudible named with - (forceRefresh; expect onNas + matched filename):

import libraryArchiveStatus from 'kody:@kentcdodds/audible/library-archive-status'

export default async function main() {
	return await libraryArchiveStatus({
		forceRefresh: true,
		items: [
			{
				asin: 'B095DTYJJC',
				title:
					'Spelling for Kids Grades 6-7: 600 English Words to Practice for the Spelling Bee',
			},
			{
				asin: 'B0FF6G74T4',
				title: 'The Unselected Journals of Emma M. Lion: Vol. 1',
			},
		],
	})
	// expect both onNas: true; filename includes "- " before the former colon clause
}

Remaining count (dry run — no archives):

import archiveRemaining from 'kody:@kentcdodds/audible/archive-remaining'

export default async function main() {
	const count = await archiveRemaining({ dryRun: true, limit: 5 })
	return {
		total: count.total,
		sample: count.remaining,
		pagesScanned: count.pagesScanned,
		downloadableCount: count.downloadableCount,
	}
}

App handler (after publish):

import { kody } from 'kody:runtime'

export default async function main() {
	const home = await kody.packageAppFetch({
		package_id: '6631998e-ad7a-4842-a5fc-1b5efd7e7332',
		path: '/',
	})
	return { status: home.status, hasHtml: String(home.body || '').includes('Connect Audible') }
}

Invalid complete (expect 4xx):

import { kody } from 'kody:runtime'

export default async function main() {
	return await kody.packageAppFetch({
		package_id: '6631998e-ad7a-4842-a5fc-1b5efd7e7332',
		path: '/auth/complete',
		method: 'POST',
		headers: { 'content-type': 'application/json', accept: 'application/json' },
		body: JSON.stringify({ redirectUrl: 'https://example.com/not-a-maplanding' }),
	})
}

Unit tests (local): npm test — PKCE verifier/challenge, OAuth URL, chapter flatten.

Playbooks

  1. First connect — app Connect → Amazon → paste maplanding → auth-status.
  2. Inventory — app Library or ./library / ./library-item. Library enrich uses ./library-archive-status (batch home audiobook_library_filename + audiobook_exists, with packageStorage SWR cache — see NAS status cache below). Home has no list-all audiobook tool; exists checks flat Title.m4b only (historical .mp3 siblings are not detected), plus an OpenAudible-style colon→- filename alternate when the title contains : / :, then a strict Media RSS Title.m4b / Title - *.m4b fallback (m4b only; left of - equals Audible title, including full Fablehaven, Book N; never MP3 / Full-Cast-as-classic; matched name is stored as nasFilename; runs even when home Unauthorized). Library UI chips: All | Not on NAS | On NAS (default All; Not on NAS = downloadable && !onNas). When Not on NAS / On NAS yields zero visible rows but more library pages exist, the client auto-fetches subsequent pages (same as infinite scroll) until a match appears, the library ends, or a safety page cap — status shows “Looking for titles not on NAS…” / “Looking for titles on NAS…”. Archive tab lists remaining ASINs/titles from /api/archive-remaining.
  3. Wishlist — app Wishlist or ./wishlist list/add/remove.
  4. Archive — app Save all remaining (N) / Save to NAS, or ./archive-remaining { dryRun?: true } / ./archive-to-nas { asin, quality?, overwrite?, title? } for purchased titles only. Resolves title → home filename candidates (canonical + colon→- ) → skip if any audiobook_exists → eligibility check → ./download-license (Adrm) → kody.mcp["home"].audiobook_import_aaxc({ aaxcUrl, key, iv, title, chapters, overwrite }). This package does Audible API only; conversion and disk write stay home-side. Prefer the skip path when the title is already on the NAS. Never log voucher key/iv.
Archive eligibility (positive playbook)

Slim ./library items (and /api/library) include archiveEligibility + archiveHint. Library list/item requests must include response group customer_rights so is_ayce / benefit_id are trustworthy on list pages.

archiveEligibilitySignals (Audible fields)UXAgent action
downloadableis_ayce: false, normal Product / codecsSave to NAS / On NAS / Re-saveArchive as today
plus_streamis_ayce: true or benefit_id: AYCLQuiet badge Plus · stream only — no Save button / no failed-Save pathDo not call ./archive-to-nas or retry licenserequest
unsupported_formatcontent_type: Show, format_type: original_recording, runtime 0 + no codecs, etc.Quiet Not an audiobook downloadDo not retry; ./archive-to-nas throws ArchiveEligibilityError (retryable: false)

Sample ASINs: Swindle B002V59RLK (Plus), Hatchet B002UZKI96 (purchase), A Christmas Carol B0779LK1TX (Show).

NAS status cache (SWR)

Library On-NAS mapping is expensive (home audiobook_library_filename + audiobook_exists per title, concurrency 8). Persist results in packageStorage key library-nas-status-v1 keyed by normalized title (+ asin index):

FieldMeaning
onNas / nasFilenameLast known flat Title.m4b exists + matched filename (canonical or colon→- )
checkedAtEpoch ms when home (or write-through) last confirmed

Policy (same judgment as Raycast command-list: short soft TTL, long max age, never block reopen on stale):

KnobValueBehavior
Soft TTL60sWithin TTL → return cache, no home MCP
Max age30 daysPast soft TTL → return stale immediately (stale: true); do not wait on home
Force refreshforceRefresh / revalidate on ./library-archive-status, or /api/library?refresh=1Bypass soft TTL; hit home; rewrite cache
Write-throughSuccessful ./archive-to-nas (save or already_exists skip)Set that title onNas: true immediately

App Library paints from the first /api/library response (often a cache hit). If archiveStatus.stale, the client soft-reloads once with ?refresh=1 after paint. Manual reload / pull-to-refresh also forces revalidate.

Cold miss (no cache entry or past max age) still hits home for those titles only; mixed pages use cache for hits and home for misses.

Edge cases

  • Signing uses ADP headers; refresh keeps bearer fresh when present.
  • Wishlist list pages start at page: 0.
  • Library / Wishlist app lists use infinite scroll (IntersectionObserver sentinel → page++ / append). Search + NAS chips filter the accumulated cache.
  • ./archive-remaining scans library pages + ./library-archive-status, counts downloadable && !onNas. Default is dryRun (count only). Pass { dryRun: false, concurrency?: 1|2, limit? } to archive. App Archive tab uses the same count via GET /api/archive-remaining and archives client-side one-at-a-time with progress + stop.
  • archive-to-nas is wired through home MCP. Fail clearly when home is missing, the library path is not writable, or the license is not Adrm (no contentUrl + key/iv). Already-present flat .m4b (canonical or OpenAudible colon→- name) returns { ok: true, skipped: true, reason: "already_exists" } with the matched filename. Home convert importResult.error.code === "audiobook_already_exists" (race after the pre-check, or overlapping Save) maps to the same friendly skip — not “Home convert did not finish… See importResult”. The app client also guards double Save per ASIN.
  • Library UI: downloadable + missing → Save to NAS; downloadable + present → On NAS + optional Re-save (overwrite: true); Plus / unsupported → quiet eligibility badge only (On NAS still if exact .m4b exists). ./library-archive-status is the batch helper; app /api/library attaches onNas / nasFilename / archiveEligibility server-side (and cache meta). Pass ?refresh=1 (or forceRefresh / revalidate on the export) to bypass soft TTL.
  • ./archive-to-nas on Plus / unsupported throws a friendly non-retryable message (ArchiveEligibilityError) — not a generic Adrm license denial. Already-present Title.m4b still returns { ok: true, skipped: true } before the eligibility throw when overwrite is false.
  • Scope is personal archive of owned / library titles; Plus stays stream-only in-library.