Program note · Writing · 6 Sep 2026
Audible has no API, so I registered a fake device and built my audiobook year anyway
Earshot pulls my Audible library nightly with a device token, whitelists what it may keep, and renders a static, copyright-safe audiobook retrospective.

I listen to a lot of audiobooks, and I wanted a year-in-review page for them: what I finished, which narrators I keep coming back to, how many hours went where. Audible does not make one, and it also does not have a public API.
Earshot is what I built instead. It pulls my library from Audible every morning and renders a public retrospective. It is a small system shaped by one awkward door and one hard constraint: there is no official way in, and I wanted to publish what I listened to without republishing anything Audible or a publisher owns.
The door: audible-cli and a device token
Scripting an Amazon login from a CI runner is a CAPTCHA-and-2FA trap, so I do not log in from CI at all.
pnpm setup:auth runs audible quickstart once, on my laptop, where I clear the CAPTCHA and the one-time code at the keyboard like a person. That registers a virtual device with Amazon. The only two things that leave the machine are that device's adp_token and device_private_key, piped into GitHub secrets over stdin. That pair is all request signing needs. The nightly pull.yml job rebuilds a minimal auth file from it using mkb79/setup-audible-cli. No access tokens, no cookies, and no password ever go anywhere.
pull.mjs then calls the raw 1.0/library endpoint with a fat set of response groups (contributors, series, media, ratings, listening status) and writes the whole response to data/library.json, which is gitignored. Raw stays raw. Nothing looks at it except the next step.
Device tokens die eventually, usually when I change my password. When the pull fails, the workflow opens a labelled GitHub issue that emails me the two-minute fix, and the next successful run closes it. That is the entire ops story, and it has been enough.
The gate, then the transform
process.mjs is a whitelist, and the whitelist is the copyright policy.
From each raw item it keeps facts about my own listening: title, authors, narrators, series, genre, runtime, the finished flag and its timestamp, community rating averages, a cover URL, and a link back to Audible. It drops everything else by construction: publisher summaries, editorial reviews, prices, order and entitlement fields. The rule is easy to audit, which is why I like it. If a field is not named in that map, it is not on the site. There is no second place to check.
One small detail I am fond of: rating counts are rounded to the nearest hundred. A retrospective wants a stable snapshot, not a data commit every time a stranger in Ohio rates a book.
The same file then aggregates the curated list into stats.json: totals, the top 12 narrators and authors, top genres, series completion, finishes by month, and the five longest listens. This is the heart of the project. Narrators are a first-class tally, not an afterthought, because in audiobooks the narrator is half the reason you pick the next one.
Smaller scripts fill in the rest. listening.mjs pulls real listened minutes per day for the heatmap. badges.mjs keeps achievement data but not Audible's artwork. series.mjs asks the public catalog for the true length of each series, because owning 3 of 5 is not "complete" no matter what the library says. enrich.mjs and geo.mjs add short CC BY-SA Wikipedia extracts and Wikidata countries for authors, cached in public/data/ with misses included so a missing author does not get looked up again every night. There is a namesake guard: the match has to be a human, with a writing occupation, whose label contains every token of the name. Without that, a common surname gets you a footballer.
Everything lands in public/data/*.json, and the job commits only if git status says that directory changed. Quiet nights produce no commits.
Rendering and shipping
The site is React plus Vite, and it is static. loadData() fetches eight JSON files in parallel and treats everything except the library and stats as optional, so a half-run enrichment degrades a section rather than the page.
The share card is an off-screen 1080 by 1350 div rasterised with modern-screenshot, text and CSS only. That "text only" was not a style choice. The Amazon cover CDN taints the canvas and breaks export, so the covers stay on the page and off the card.
deploy.yml runs on every push to main, including the bot's data commits: pnpm build, then wrangler deploy to an assets-only Cloudflare Worker on a custom domain. No server, no R2, no database. The data is public and about 170 KB, so aggregation belongs at build time and the edge just serves files. Anything more would be infrastructure for its own sake.
The reusable shape
Earshot is one instance of a pattern I keep reaching for. AniYears, which does the same for my MyAnimeList history, is the other. The shape is:
- A source that holds my own activity data, with a credential set up once, locally.
- A scheduled pull into a gitignored raw file.
- A whitelisting transform that emits small, public, curated JSON.
- A static retrospective that reads only that JSON, deployed on push.
The transform is the part worth getting right. It is where the privacy boundary and the copyright boundary both live, and once it is honest, everything downstream is presentation. Getting the door open was the annoying bit. Deciding what may walk through it was the real work.
The retrospective is live at earshot.danmat.dev and the source is at github.com/DanMat/earshot. If you have a service you use every day that has no API and you want a year-in-review from it, the four steps above are a decent place to start. I would like to know what your awkward door was.