@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
| Store | Purpose |
|---|---|
packageStorage key audible-auth-v1 | Source 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
- Open hosted app Connect tab (or
./start-authwith{ locale: 'us' }). - Kent signs in on Amazon; paste final maplanding URL into the app or
./complete-auth{ redirectUrl, authSessionId }. - Confirm with
./auth-status→signingReady: true(reads package storage). - 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-v1is cleared — client refreshes access tokens fromrefresh_tokenand rewrites package storage.
PKCE verifier + locale + serial live in packageStorage() under
auth-session:{id} for ~30 minutes while login is in flight.
Import paths
| Export | Import |
|---|---|
| overview | kody:@kentcdodds/audible |
| client | kody:@kentcdodds/audible/client |
| library | kody:@kentcdodds/audible/library |
| library-item | kody:@kentcdodds/audible/library-item |
| wishlist | kody:@kentcdodds/audible/wishlist |
| auth-status | kody:@kentcdodds/audible/auth-status |
| start-auth | kody:@kentcdodds/audible/start-auth |
| complete-auth | kody:@kentcdodds/audible/complete-auth |
| download-license | kody:@kentcdodds/audible/download-license |
| archive-to-nas | kody:@kentcdodds/audible/archive-to-nas |
| library-archive-status | kody:@kentcdodds/audible/library-archive-status |
| archive-remaining | kody:@kentcdodds/audible/archive-remaining |
| nas-status-write-through | kody:@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
- First connect — app Connect → Amazon → paste maplanding → auth-status.
- Inventory — app Library or
./library/./library-item. Library enrich uses./library-archive-status(batch homeaudiobook_library_filename+audiobook_exists, with packageStorage SWR cache — see NAS status cache below). Home has no list-all audiobook tool; exists checks flatTitle.m4bonly (historical.mp3siblings are not detected), plus an OpenAudible-style colon→-filename alternate when the title contains:/:, then a strict Media RSSTitle.m4b/Title - *.m4bfallback (m4b only; left of-equals Audible title, including fullFablehaven, Book N; never MP3 / Full-Cast-as-classic; matched name is stored asnasFilename; 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. - Wishlist — app Wishlist or
./wishlistlist/add/remove. - 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 anyaudiobook_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.
archiveEligibility | Signals (Audible fields) | UX | Agent action |
|---|---|---|---|
downloadable | is_ayce: false, normal Product / codecs | Save to NAS / On NAS / Re-save | Archive as today |
plus_stream | is_ayce: true or benefit_id: AYCL | Quiet badge Plus · stream only — no Save button / no failed-Save path | Do not call ./archive-to-nas or retry licenserequest |
unsupported_format | content_type: Show, format_type: original_recording, runtime 0 + no codecs, etc. | Quiet Not an audiobook download | Do 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):
| Field | Meaning |
|---|---|
onNas / nasFilename | Last known flat Title.m4b exists + matched filename (canonical or colon→- ) |
checkedAt | Epoch 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):
| Knob | Value | Behavior |
|---|---|---|
| Soft TTL | 60s | Within TTL → return cache, no home MCP |
| Max age | 30 days | Past soft TTL → return stale immediately (stale: true); do not wait on home |
| Force refresh | forceRefresh / revalidate on ./library-archive-status, or /api/library?refresh=1 | Bypass soft TTL; hit home; rewrite cache |
| Write-through | Successful ./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 (
IntersectionObserversentinel →page++/ append). Search + NAS chips filter the accumulated cache. ./archive-remainingscans library pages +./library-archive-status, countsdownloadable && !onNas. Default is dryRun (count only). Pass{ dryRun: false, concurrency?: 1|2, limit? }to archive. App Archive tab uses the same count viaGET /api/archive-remainingand archives client-side one-at-a-time with progress + stop.archive-to-nasis 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 convertimportResult.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.m4bexists)../library-archive-statusis the batch helper; app/api/libraryattachesonNas/nasFilename/archiveEligibilityserver-side (and cache meta). Pass?refresh=1(orforceRefresh/revalidateon the export) to bypass soft TTL. ./archive-to-nason Plus / unsupported throws a friendly non-retryable message (ArchiveEligibilityError) — not a generic Adrm license denial. Already-presentTitle.m4bstill returns{ ok: true, skipped: true }before the eligibility throw whenoverwriteis false.- Scope is personal archive of owned / library titles; Plus stays stream-only in-library.