Audible
Phases 4a and 4b of the Bridge plan (BRIDGE-PLAN.md Phase 4, the workspace's
.phase-4/plan.md): the Bridge signs in to Audible accounts, lists what each owns
and matches it against a books library on the server, and the console shows the
result (4a); the Bridge backs titles up into the library, as the server asks
(audible.import, 4b). Places don't sync
yet (4c). The player has no Audible surface.
The Audible client came from audiosilo-manager: its audible, importjob,
placement, source, transfer (Local only) and progress packages were
imported with their history (git filter-repo), so git log --follow reaches
the manager's fixes. The manager's registry was imported and deleted in the same
change: the Bridge's vault (internal/secrets) and the server's audible_titles
columns replace it. AAX activation bytes, the "sims" series watch and the folder,
probe and OpenAudible sources were not imported.
Who holds what
| Where | What |
|---|---|
| The Bridge's vault | each account's sign-in, sealed as audible_<id> (secrets.AudibleName): the access, refresh and adp tokens, the device's RSA key, its serial, the customer id and marketplace (audible.Credentials.Marshal). Full Amazon account access, so never anywhere else |
The Bridge's bridge.db | audible_accounts (migration 0009): id, label, marketplace, customer_hash (sha256 of the customer id, never the id), server_id (the server the mapping was last recorded on), user_id/user_name, library_id, policy, sync, created_at, last_ok_at, last_poll_at, last_titles, last_matched, refused_at, last_error, and device_profile (migration 0010, below). Unique on (customer_hash, marketplace) where the hash isn't empty. No credential |
| The server | the mapping and the titles (0063, 0064, 0065, below), the poll schedule, and what to back up and when. It never talks to Audible and never holds a credential or a book's key |
No credential reaches the store, job params or results, logs, the heartbeat or the agent staging folder (the Bridge's tests walk all of them for every stub secret). The pasted sign-in address holds a one-time authorization code: the page API takes it and never answers it back or logs it. Errors carry Amazon's or the server's answer, never a request body or a token, and that answer is redacted of account ids before it becomes an error.
The sign-in (internal/audibleacct)
Amazon doesn't allow a third-party sign-in page, so the Bridge uses the Audible app's own flow and the admin pastes back where it ends:
- Start (
Manager.Start(marketplace), pagePOST /api/accounts/audible/sign-in {"marketplace"}): a PKCE pair and a device serial (audible.NewPKCE,NewDeviceSerial) held in memory only forLoginTTL(15 minutes; at most 8 at once, the oldest dropped), and the authorize URL (BuildAuthorizeURL). The answer is{login, url, marketplace, expires_at}. - The admin signs in at
urlin their own browser. Amazon lands on/ap/maplanding?...openid.oa2.authorization_code=..., a page that doesn't exist; the admin copies its address. - Finish (
Manager.Finish, pagePOST /api/accounts/audible {login, redirect_url, label, user_id, library_id, policy, sync}): the mapping and the code are checked first (a page without a code keeps the login for another try), then the login is spent, the device registered (audible.Register: code + verifier ->Credentials), the mapping recorded on the server (PUT /bridge/audible/accounts/{account}) before anything is sealed, the credentials sealed asaudible_<id>, and the row stored. Any step after the registration that fails deregisters the new device and puts back what it changed (best effort): a new account is DELETEd on the server, an existing one's earlier sign-in goes back in the vault and its earlier mapping is PUT again. A failed sign-in leaves no device and the account as it was.
The device is named "<first name>'s AudioSilo" in the Amazon account's device
list (audible.DeviceName, decisions 14/16: "AudioSilo", not "Bridge"). The
account id is the Bridge's own, ^[a-z0-9]{8,32}$, opaque to the server.
A sign-in of an Amazon account already here (same customer_hash and
marketplace) replaces it: same id, the old device deregistered afterwards (an
earlier sign-in that can't be opened is logged and its device stays). Finish
runs one at a time from that lookup until the row is stored (finishMu, backed by
the unique index), so two sign-ins of one Amazon account finishing together end as
a sign-in again, not two accounts. A label the Bridge made ("<person> (<STORE>)") follows the person when a mapping changes; a typed one stays.
Map (page PUT /api/accounts/audible/{id}) records the new mapping on the
server first, then locally. The page checks that the library is a books library
the heartbeat listed and that the person is one GET /bridge/users lists.
Remove (page DELETE /api/accounts/audible/{id}, answering {audible, warning}) forgets locally first (the vault, then the row; it takes finishMu
meanwhile), then DELETEs on the server, then deregisters
(audible.Deregister: POST /auth/deregister with
deregister_all_existing_accounts: false, this device only, refreshing an access
token within 2 minutes of expiry first). It runs to the end even if the page goes
away. A server or Amazon failure is a warning for the page, never the account
kept. When the server's DELETE failed, the server keeps an orphaned account until
an admin removes it
(DELETE /admin/audible/accounts/{account}):
from the console while the module is on, else as an API call, since the console
hides its Audible card and page while the module is off.
Refused. A 401/403 on a signed call (audible.IsRefused) marks the account
refused (refused_at, last_error; the page's Sign in again), unless a sign-in
again replaced the device meanwhile, in which case Library lists again. In 4a a
refused account's poll fails (it isn't parked). Signing in again clears it, and
so does a poll that succeeds, except a throttled refusal (below): a throttled
device still lists, so a poll keeps it and only a sign-in again ends it.
The device profile
Since about September 2026 Amazon denies every license to a device registered with
the older iPhone profile (app 3.56.2, iOS 15/17, software 35602678, a 32-character
upper-case hex serial), with three reasons (Membership/RequesterEligibility,
Ownership/CustomerThrottled, Client/RequesterEligibility, the customer id
quoted), while listing still works (mkb79/Audible#909 and PR #910; Libation's
device-registration notes agree). The Bridge registers with the current one
(internal/audible/profile.go): app 4.60, os 26.2, software 46000856, one
constant set that register, the token refresh and deregister share, and a
40-character [A-Za-z0-9] serial from crypto/rand (NewDeviceSerial). The user
agent on every signed call and download is built from the same set
(Audible/46000856 CFNetwork/3860.300.31 Darwin/25.2.0).
audible.ProfileVersion("4.60/26.2/46000856") is recorded inCredentials.ProfileatRegisterand in the row'sdevice_profile(Bridge migration0010_audible_profile,''for an account signed in before it was kept).audibleacct.OldProfile(a)flags an account whose profile isn't the current one: the page'sold_profile(an amber Sign in again badge and "Sign in again to keep backups working. ..."), and the setup itemaudibleis not done ("N account(s) need signing in again to keep backups working"). Changing the constant set flags every account registered before it.denialKindchecks the customer's throttling (customerthrottled, "being throttled") first, asaudible.ErrThrottled(a bare "Throttled", a rate limit, staysErrDenied).Manager.signedretries a throttled license once when a newer sign-in replaced the device meanwhile, else marks the account refused withaudibleacct.ThrottledTextand answers the denial. The import failsaudible_refusedwith the advice to sign in again (neverno_rights), and every later import of the account failsaudible_refusedat once, before the title read or a license, until a sign-in again. (Any refused account's imports fail this way, after a second read of the account: a sign-in refusal with Audible's sign-in advice until a sign-in again or a poll that succeeds clears it.)- A sign-in again of the same Amazon account replaces the device and deregisters the old one (as before). Existing accounts keep their old registration until the owner signs in again after updating.
The page's GET /api/accounts/audible answers {accounts, marketplaces, policies, paired, libraries, users, users_error}: the people are read live from the server's
GET /bridge/users, the libraries are the heartbeat's books libraries, and an
account whose server_id isn't the paired server's is other_server: true. The
page's Audible writes have a 15-minute write timeout (audibleWriteTimeout): each
calls Amazon or the server, whose backoff may wait minutes.
The audible client (internal/audible)
Signed calls (RSA PKCS#1 v1.5 over method, path and query, date, body and the adp
token; no bearer), Library (pages of 1000, deduped, at most 250 pages;
UnavailableReason says preorder, show/periodical or no download rights),
LastPositions (25 ASINs a request, split on a 400) and SetLastPosition,
License + DecryptVoucher. Phase 4a added
RefreshAccessToken, Deregister, a Range-resumable Download capped at
MaxBytes (4 GiB by default), last_updated from lastpositions, a backoff on
429/503 (Retry-After seconds or a date, else 2 s doubling, 4 attempts, a wait
over 2 minutes fails at once, each attempt signed afresh) and injectable endpoints
(WithEndpoints, WithHTTPClient; EndpointsFor(marketplace) the real ones).
Marketplaces: us, uk, de, fr, ca, it, au, in, jp, es, br.
Phase 4b added Client.Title (GET /1.0/library/{asin}, ErrNotInLibrary on a
404), Client.License answering a self-redacting License {URL, Key, IV, Size}
with ErrNotReleased, ErrNoRights, ErrDenied from a denial's reasons and
ErrDRMFree for a title granted only as a DRM-free MP3, Decrypt, and the
resumable Download's state file, If-Range and stall watchdog (see
Resume and temp files). The manager's Source.Acquire
went: audiblejob runs the steps.
Redacted account ids
Amazon's error bodies quote the customer id ("Customer id [A1...] being
throttled"), so audible.RedactAccountIDs (redact.go) runs on Audible's answer as it
becomes an error (the snippet, a license's denial reasons): customer ids (labelled,
or A plus 10 or more capitals with a digit), amzn1.* ids, device serials
(labelled, 32+ hex, or 40 letters and digits), emails and tokens go; the ASIN
(B0... or 10 digits) and the human part stay. RedactKnown and
Credentials.Redact also remove the account's own customer id and serial by value,
and audibleacct's Service wraps every error it answers with them. audiblejob's
job errors and recorded poll failures pass the redactor again (a Failure redacts
the errors it quotes, never its library paths or titles; RedactedError keeps
errors.Is). So no log, job error, result, progress or anything sent to the server
carries an account id.
The poll: audible.poll
On the server (internal/bridge/audible.go) the kind is internal (refused by
POST /admin/bridge/jobs with 400) and quiet (no notifications), with params
{"account", "template"} (strict; template is the account's placement template
at queue time, omitted when "", bounded to 500 bytes but never parsed, so a
template the grammar later refuses never stops the polls), no library or path,
deduped per account
(audiblePollSuffix), owned by the audible module (ModuleFor: a queued poll is
held while the module is off), and it promotes: a request that finds the
account's poll open raises its priority, never lowers it. Enqueue keeps only the
newest ended poll of each account (pruneAudiblePolls). Removing an account cancels
its open poll (CancelAudibleRemoved), and so does deleting the library it is
mapped to (CancelAudiblePaused): both are IsCancelReason's, and the console
keeps a copy (SERVER_CANCEL_REASONS).
The scheduler (internal/audible.RunScheduler, the module's Start) passes
at once, then every minute while the module is on: with an online Bridge that
offers the kind, it queues up to 20 due accounts (catalog.DueAudiblePolls: not
paused, mapped to a person an account may still be mapped to, last_queued_at
older than poll_hours, 6 by default) at priority -10 and records
last_queued_at, so a failed poll waits its interval. The console's Check now
(POST /admin/audible/accounts/{account}/poll) queues at a person's priority
(0), or raises the open one to it; a request that finds the poll still queued
also gives it the current template, so Check now after a template change plans by
the new one (a claimed poll runs as claimed).
On the Bridge (internal/audiblejob, the light lane) a poll:
- checks the account is this Bridge's and was mapped on the paired server (else it fails, asking to map it again);
- lists the Audible library (a refusal is the account's: the job just fails);
- reads the account's library on the server, 500 books a page
(
GET /bridge/libraries/{id}/books, at most 2000 pages); - skips records without a usable ASIN, and states the rest from
UnavailableReason:""isavailable,"pre-order"preorder, anything elseunavailablewith the reason; - matches them with
importjob.BookMatcherover[]link.LibraryBook(audiosilo-server/pkg/match.BestWithundermatch.ImportOptions(): ASIN exact first, then author + series + title tokens; a book in several series is a candidate under each; narrators join the person gate; see Matching rules), thenimportjob.KeepBestsettles books claimed by several titles.howisasinwhen the book's ASIN is the title's, elsefuzzy. A book path goes out only as.(a library that is one book) or a clean path below the root, as the server takes it; - plans each unmatched title (contested included, never a matched one):
where a backup would go,
planned(see Planned destinations), with no Amazon request; - PUTs the titles in calls of 500 under one poll id (the job, the attempt and a
random suffix), the last one
final(an empty library sends one empty final call, so every title becomesgone), with each title's authors cut to 20; - backfills ASINs (
PUT /bridge/libraries/{id}/enrichment) for each matched book without one, unless the book is contested (derive nothing durable from a doubtful match) or the title is a preorder (not out yet: the book is another edition).asin_setand a moved book are skipped, other failures counted. The PUTs are paced (see Enrichment pacing).
The result is counts only, never a title or a credential: account, poll_id,
titles, available, preorder, unavailable, skipped, matched,
matched_asin, matched_fuzzy, contested (books more than one title claimed),
enriched, enrich_failed, enrich_deferred (omitted when 0), books.
matched counts the titles that kept a match.
A poll that finished records last_poll_at, the counts and clears a refusal on the
Bridge's row; one stopped, cancelled or lost records nothing.
Matching rules
BookMatcher runs the ASIN step over every candidate, then match.BestWith under
match.ImportOptions() over the candidates past the person gate. The options turn
on every opt-in rule of the server's pkg/match (the table is in
the server's pkg/match); the server's own
callers keep the default rules. BookMatcher.MatchScored hands the winning score
(match.ScoreASIN for an ASIN match) to the contested rule.
Positions. The books listing sends each series' position as a string, and fills
an empty main series position from a code leading the book's folder name
(DH06 - ... is "6", LAM05-06 the range "5-6"; the full rule is at
GET /bridge/libraries/{id}/books).
On the Bridge a book with a range position in any of its series spans books: its
candidates sit at the range's start (rangeStart), and a title that is no range
never sees them, so a single Audible volume never matches an omnibus folder (and
never gives it that volume's ASIN). An Audible title with a range position and no
sequence is placed at the range's start, so a number in its title ("Pack 3") never
stands in for the position Audible gave. Such a title tries the spanning books
that start where it does first, then the single volumes (a pack may be stored as a
folder numbered as its first volume); a spanning book that starts elsewhere is
another pack. A pack matched that way to a single-volume folder is kept as a match
but not enriched (importjob.SpanMismatch), so the folder never takes the pack's
ASIN. An ASIN match is never held back by any of this.
Contested books. importjob.KeepBest settles a path claimed by several titles
(Claim{Path, Score, Work, Agrees, Disagrees}). An ASIN match (match.ScoreASIN)
keeps it. Otherwise, when some claims' series position agrees with the book's
(importjob.PositionAgrees(rec, b): the title's SeriesPosition as one number
against the book's position in the title's series, else the book's main series'
position, where the books listing puts a folder code's number; a range or no number
on either side is neither), only those compete, so the volume whose number is the
book's keeps it even over a higher score (MatchScored itself is not gated by
it). Then the single best score keeps the match. A tie for the
best keeps none, unless every tied title is the same work (WorkKey: the folded
title, the folded authors in any order and the series position); then all keep it
(one book owned twice, under two ASINs). A losing title whose position
disagrees with the book's is another volume: it goes out plainly unmatched, no
reason. The other losers are PUT with no match, reason: "contested"
(link.ReasonContested, set only on a title with no other reason) and
contested_path (the book's path, omitted otherwise), so the console shows them as
not here, names the book and the other titles, and Back up everything skips them.
A contested path gets no ASIN backfill, not even from the title that kept it. Not
this book doesn't settle a contest (the Bridge never reads rejected): a
follow-up.
Planned destinations
Each unmatched title of a poll carries planned (audiblejob/plan.go,
titlePlanner): the import's own destination() for the same record, the same
books listing and the params' template, without the title's dest_path (the
console's override is checked when the backup starts):
"planned": {
"library_id": 1,
"path": "Ben Aaronovitch/Rivers of London/2 - Moon over Soho/Moon over Soho.m4b",
"file": "Moon over Soho.m4b",
"kind": "series_existing",
"siblings": 7,
"state": "joins",
"conflict": "",
"template": ""
}
pathis the file's path afterspelled(as the disk spells the folders that exist),fileits name; both""whenstateiserror.kindis the placement's:series_existing,series_neworstandalone;siblingshow many of the series' books the library lists.state:new;joins(siblings, or the path's top folder is a listed book's or, when mapped, a folder at the mount);existswithconflictfile,bookoraudio(vacant's refusal,link.ConflictFile|Book|Audio);errorwith the reason inconflict(a template that renders no valid path, say).templateis the template the plan used, as the params gave it (""for Auto): the console notes when the account's template changed since.- Disk checks (
spelled,vacant, free functions over atransfer.LibraryFS) run only when the library is mapped at the Bridge's mount, through a per-poll directory cache; otherwise the books listing alone decides (a listed book's folder isexists/book).
Release dates
audible.Library takes a title's date from release_date, else issue_date,
keeping only a plausible one (plausibleDate: parseable, a year before 2100 and at
most two years after now). With neither, the date is unknown (release_date "")
and reads as released: Audible sends placeholders such as 2200-01-01 for titles it
has had for years, and those must never be stored as preorders. unavailableReason
checks the delivery type (Periodical, PodcastParent) and customer_rights
before the preorder rule, so a show or a title without download rights is never
called a preorder. A title whose date was set aside is logged once per listing
(its ASIN and title, both dates, content_delivery_type, content_type and
is_consumable_offline; never a token or the customer id) through the client's
WithLogger.
Enrichment pacing
A first poll can backfill hundreds of ASINs, one PUT each, and the server's
address limiter (shared with the
house's players) answers a quick run 429. The server is unchanged; the Bridge
paces the PUTs (audiblejob.enrich): at most one per 200 ms (enrichGap). On a
429 it waits for the Retry-After (seconds or an HTTP date, read into
link.APIError.RetryAfter), or else 2 s, doubling with each 429 in a row. It
then retries the same book and doubles the gap, up to 2 s.
The poll leaves the rest of the backfill to the next poll after:
- more than five
429s in a row; - a
Retry-Afterover a minute; - 10 minutes of enriching (
enrichBudget).
Those books still have no ASIN, so the next poll picks them up again. Their count
is the result's enrich_deferred. Only a cancelled job fails the poll.
Registering the audible.poll kind is what adds audible to the heartbeat's features
(bridge.FeatureAudible), which the audible module needs.
Backing up a title (audible.import)
Phase 4b. The server decides what to back up and when (a console request, Back up everything, an account's policy after a poll, a preorder's pending request); the Bridge downloads the title for the account, removes the DRM and places it in the account's library. The server never talks to Audible and never sees a key.
The job
| Kind | audible.import (bridge.KindAudibleImport; the Bridge's audiblejob.ImportKind), feature audible |
| Server spec | internal (POST /admin/bridge/jobs refuses it with 400), writes (a writable books mapping), libraryKind books, no book path (bookNone), owned by the audible module (ModuleFor: held while it is off), not quiet: a done or failed one is notified, named by the title (notifyBridgeJob reads the title's name) |
| Dedupe | one open job per account and title (audibleImportSuffix: |<account>|<asin>); a repeat returns the open one |
| Bridge lane | download (jobs.LaneDownload, 1 slot by default, shared with podcast.fetch), and one import per account at a time on top (a per-account gate, Importer.gate) |
| Library | the job row's library_id is the account's library at queue time |
Params are built by the server (catalog.importParams, from the account and the
title at queue time), strict on the server (audibleImportParams), read leniently by
the Bridge (audiblejob.ParseImportParams: unknown fields ignored, the ASIN
upper-cased):
{ "account": "k3v9x0p2m4q8r1t5", "asin": "B0FAKE0002", "library_id": 2, "dest_path": "", "template": "" }
dest_path: the console's destination for the title (audible_titles.dest_path),""for none. A library-relative file path, or a folder when its last segment has no audio extension. The server checks it withbridge.CheckAudibleDestPath(the rules are in the API reference).template: the account's placement template (audible_accounts.template),""for the Bridge's automatic placement. Checked withbridge.CheckAudibleTemplate(below).
Both are copied in at queue time: editing either later never changes a queued job.
Progress is {"step", "message", "done", "total"} with step one of
license (also while waiting for the account's gate or the mapping check),
download, decrypt, place, rescan, enrich, in that order (done/total
are bytes during download and place).
Result of a done job:
{
"library_id": 2,
"path": "Mara Quill/OC02 - The Copper Grove",
"files": [{ "path": "Mara Quill/OC02 - The Copper Grove/The Copper Grove.m4b", "size": 67984 }],
"bytes": 67984,
"enriched": true
}
(The fake's title placed beside Mara Quill/The Glass Orchard, book 1 of its
series in a folder with no code: Auto kept the author folder and guessed the code.)
path is the book path POST /bridge/rescan answered for the placed file (a
single-file book is its file), files what was placed, enriched whether the
book now carries the title's ASIN (false: it had one already, the server refused
it, asin_set, or the file joined a book that isn't the title's).
Failures are audiblejob.Failure {"code", "message"}, both as the result and
as the error, so the server reads the code from the result (bridge.AudibleImportCode:
a park's code, else the result's code, else an error starting with a known code):
| Code | When |
|---|---|
not_released | the title is a preorder, or Audible's license denial says it isn't out (audible.ErrNotReleased) |
no_rights | the title isn't in the account's library (ErrNotInLibrary), Audible won't let it be downloaded (UnavailableReason: no rights, a show or periodical), or the license is denied for rights (ErrNoRights) |
audible_refused | Audible refused the account's sign-in (audibleacct.ErrRefused: the account is marked refused) or the license for another reason (ErrDenied); or denied the device as throttled (ErrThrottled, a device on an older profile): the message is ThrottledText's advice to sign in again on the Bridge, the account is marked refused and its later imports fail at once until a sign-in again |
not_writable | the library isn't the account's, not a books library the server lists, or not mapped and writable at the mount in use; the destination or template gives no valid path (transfer.CleanRel's refusals, a hidden segment included; another audio extension than .m4b, .m4a, .mp4); a path the Bridge can't follow (a file where a folder goes, a link out of the mapping, an unreadable folder); a read-only filesystem or a permission refusal at the install |
exists | anything at the destination (a different file, a folder, a link), or a destination inside a book the library already has (its folder is a listed book's, or already holds audio): checked before the license, and again at the install |
disk_full | free space short in the Bridge's temp folder (2.2x the size, less what the partial holds) or at the library's nearest existing folder (1.1x, transfer.DefaultHeadroom), checked before a byte is written; or ENOSPC/EDQUOT during the download, decrypt (ffmpeg's words too) or copy. A free space that can't be read is no refusal |
offline_url_expired | the presigned download URL answered 401/403/410 again after the one new license |
unknown_account | not this Bridge's account, or mapped on another server |
A title offered only as a DRM-free MP3 (audible.ErrDRMFree) fails with no code
(not backed up in 4b), as do a download over DownloadOptions.MaxBytes
(audible.DefaultMaxDownloadBytes, 4 GiB) and other errors.
Not writable while running. A mapping check not made yet for the mount is
waited for (mappingPoll, 1 s), no park. An unwritable mapping refuses a new
job at once; a resumed job, and any job at the place step (the mapping is
checked again before the copy: it may have changed during the download), parks
not_writable (Reporter.Park, progress.park.code) and runs on once the
mapping is writable again, keeping its download.
The steps on the Bridge
audiblejob.Importer.Run (import.go):
- Gate and checks. Takes the account's gate, checks the account (this Bridge's, mapped on the paired server) and the library (the account's, mapped and writable).
- The title (
Manager.Title,GET /1.0/library/{asin}signed): a preorder isnot_released, an unavailable reasonno_rights. - The destination (
audiblejob.Destination):dest_pathfirst (the template isn't consulted), else the template (placement.ModeTemplate), else Auto (importjob.Planover the paged books listing: the series siblings' folder and code convention, an existing author folder's spelling, elseAuthor/Title; an empty author folder dropped). Thentransfer.OnDiskspells it as the disk spells the folders that exist (the path the server indexes on a case-insensitive disk), andvacantrefuses an occupied destination (exists). All before the license and the download. - License (
Client.License): the AAXC URL, key, IV and size. - Download to
<data>/audible/tmp/<account>-<asin>.aaxc.part(below). - Decrypt (
audible.Decrypt:ffmpeg -audible_key <key> -audible_iv <iv> -c copy -f ipod) to<account>-<asin>.m4bin the same folder, then the partial goes (room for the copy). - Place (
transfer.Local.Place, below), after the mapping check again. - Rescan (
POST /bridge/rescanwith the placed file, 3 tries): the server answers the book path. A failure here says the book is placed. - Enrich (
PUT /bridge/libraries/{id}/enrichmentwith the title's ASIN; see Enrichment after placing).
Resume and temp files
The temp folder is <data>/audible/tmp/ (0700), one set of files per account and
title:
| File | What |
|---|---|
<account>-<asin>.aaxc.part | the partial download, and its state file .aaxc.part.state (audible.StatePath: {size, etag, last_modified}, written before the body) |
<account>-<asin>.m4b | the decrypted copy, always removed when the run ends |
<account>-<asin>.placed.json | the placement marker, written after the install (below) |
audible.Download resumes a partial only when its state file agrees with
DownloadOptions.ExpectedSize (the license's size) and holds a validator or a size.
The Range carries If-Range (a strong ETag, else Last-Modified), so a 200
replaces the partial; a 206 at another offset or for another total, or a 416
for another file, starts again (the partial removed first; one it can't remove ends
the download). A short body is io.ErrUnexpectedEOF; a 200 without a length is
held to ExpectedSize. Each attempt runs under a stall watchdog (downloadStall,
2 minutes with no answer or no byte: ErrStalled, the partial kept). A ctx
cancel stops it mid-body. Its errors never name the presigned URL's query.
The importer's loop: a dropped connection, a stall or a 5xx resumes from the
partial, waiting 2 s, then 8 s; only downloadAttempts (3) attempts in a
row that add nothing fail the job, so a long download over a link that drops now
and then finishes. A 401/403/410 licenses once more (a URL that expired over
a long wait or a restart), then offline_url_expired.
Who keeps the partial (decision 6), from the gate on:
- a stop (
jobs.Resumes: the Bridge shutting down, a refused token) or a lost claim (jobs.Lost: the lease lapsed and the server hands the job out again) keeps the partial and its state for the next run; - a cancel or a failure deletes them (
audible.RemovePartial); Importer.Abandondeletes them for a job the runner ends without running it, unless its claim was lost;- the decrypted copy always goes.
The start-up sweep (audiblejob.SweepTmp, from app.newAudible) keeps only
*.aaxc.part files younger than PartialMaxAge (7 days) with their state, and
placement markers as young; everything else in the folder goes.
The placement marker. After the install the Bridge writes
<account>-<asin>.placed.json (library_id, the request's dest_path and
template, the placed path, size, mtime_ns, the join verdicts). While the
library holds exactly that file (its size, and its modification time within
markerSlack, 2 s), a retry of the same request goes on from the rescan with no
second license or download and the same result; a request whose destination turns
out to be that file finishes it too. A request for another destination places anew
(the earlier copy stays where it is, logged). A changed or replaced file drops the
marker (exists); one that can't be read now keeps it and fails, to try again. The
result removes it.
The hardened transfer (internal/transfer)
transfer.Local.Place is the only writer into a library, hardened in 4b before it
wrote one:
- Contained. The mapping's root is opened first as an
os.Root(a missing root, or a file, fails before the source is read) and every call goes through it, so a symlink can't lead out.transfer.CleanRelappliesmapping.SafeJoin's rules and refuses an empty path and any hidden (dot) segment (the server never indexes one):ErrInvalidPath. - No replace. The same bytes already at the destination are
AlreadyPresent(an earlier run's copy); anything else there (a different file, a folder, a link) isErrExists. The copy is staged as a.audiosilo-import-*.partdotfile beside the destination (0644, synced, size-checked) and installed withfsutil.InstallNoReplace(an exclusive hard link, elseRenameNoReplace:renameat2(RENAME_NOREPLACE)on Linux, a check-then-rename elsewhere; the podcast episode install's primitive, moved intofsutil). A file that raced in is kept (ErrExists, orAlreadyPresentwhen it is these bytes). A failure or cancel leaves no staged copy. - Free space. 1.1x the file (
DefaultHeadroom) free at the nearest existing folder, elseErrDiskFull(an unreadable free space is no refusal; ENOSPC/EDQUOT during the copy isErrDiskFulltoo).
Why joining an existing book is exists. A backup is a book of its own: the
server's scan would make a file placed in a listed book's folder, or a folder that
already holds audio, part of that book, and the server refuses such a done import
(ErrImportNotOwn, below), so vacant refuses it before the license (step 3).
The one join left is a dest_path into a new disc folder of a disc set, which
never takes the ASIN.
Enrichment after placing
The book the rescan answers decides. joinedBooks gives each book the file could
join (at its own path, its folder, or a disc set's folder above it) a verdict:
importjob.BookMatcher picks that book for the title and it isn't
importjob.SpanMismatch (the poll's rule). A book with a verdict takes the ASIN only
on it; any other book is new (the title's own, a pack placed as a pack included)
and takes it. The verdicts are kept in the placement marker. The one PUT waits out
429s as the poll's enrichment does (limitWait), then gives up with
enriched: false; asin_set and a moved book are enriched: false, no failure.
Secrets
The license's key and IV live in memory and in ffmpeg's argv only: never a log,
progress, result, error or file (the import's tests walk all of them for the stub
key). audible.License redacts itself (String, GoString, LogValue;
MarshalJSON refuses), ffmpeg's stderr is redacted of the key and IV (any case:
ffmpeg echoes a bad key), and a voucher that won't parse is reported without its
bytes. The argv is visible in the container's process table: accepted. The download
URL is presigned: errors name it without its query. The account's sign-in stays in
the vault (Who holds what); Title and License go through
Manager.signed (a refusal retries once with a newer sign-in, else marks the account
refused), Download only opens the sign-in (its 401/403 is an expired URL, the
import's to license again, never the account's refusal).
On the server: queueing (internal/audible/imports.go, catalog/audible_import.go)
audible.Queues queues a job (bridge.Enqueue) and records it on the title
(catalog.SetAudibleImport: import_job_id, clearing import_pending). A repeat
finds the open job by its dedupe key and records it too (a crash between the two
heals). Priorities:
| Who | Priority | Requester |
|---|---|---|
a console Back up (priorityBackup), and a preorder's pending request when a poll queues it | 0 | the admin; the server (0) for a pending one |
an account's policy after a poll (priorityPolicy = schedulerPriority) | -10 (as the podcast scheduler's fetches and the scheduled polls) | the server |
Back up everything (priorityBackupAll) | -20 | the admin |
Back up everything sits below new podcast episodes, so a bulk backup in the Bridge's one download slot never holds them back for days.
"On this server" is a match not marked Not this book (rejected = 0): Back up's
already_matched, the policy's pending drop, the titles' matched filter and the
accounts' matched count all read it so.
Back up (RequestAudibleBackup): unknown_account/unknown_title;
account_paused; already_matched; import_open (an open job, or a pending
request); a preorder stores import_pending = 1 and answers pending (no job;
the request survives a restart); not_available for unavailable or gone.
Contested titles are allowed, and so is one whose last backup is done once it is
no longer on this server (the admin decides; retryImportSQL is Back up
everything's only).
Back up everything (AudibleBackupAll, backupAllSQL, also the
backup_estimate): state = 'available', no match, not rejected, not ignored,
not contested (contestedSQL: reason = 'contested'), and retryImportSQL: no
backup yet, or the latest isn't open and isn't a done one whose result's book is
still indexed. So a failed or cancelled backup is tried again, a backed-up title
whose book left the library (deleted or moved) is queued again, and a refused fold
(ErrImportNotOwn) isn't queued again every time. It stops at the first refusal of
the queue (kind_unavailable, library_not_writable: none would be taken) with
what it queued. The estimate is runtime_s x 8000 bytes (64 kbps AAXC), labelled
approximate.
The policy (AudiblePolicyImports, run by afterAudiblePoll after each
final titles PUT while the module is on and an online Bridge offers
audible.import, within 10 s; what it misses the next poll asks again). None for a
paused account, or one mapped to a person it may no longer be. Pending requests now
on this server are dropped first. Then autoImport.wants (a table test) per title:
available, not on this server, nothing open, not ignored; then a pending request
always (at priority 0); otherwise not rejected, not contested, never attempted
(any import_job_id), first_seen after policy_since, and all, or series
when the account's person has a book of one of its series (seriesOwner: by series
key, main or other series, in a library their scopes reach). A refusal of the queue
that means none will be taken (library_not_writable, kind_unavailable) ends the
pass quietly.
policy_since makes policies new purchases only (decision 2): set to now at the
account's creation (the Bridge's first PUT /bridge/audible/accounts), when the
console turns all or series on (PatchAudibleAccount; switching to manual
leaves it), and moved to the account's first complete poll (PutAudibleTitles
when last_poll_at was NULL), so that poll's titles count as the library the
account already had. The Bridge's account PUT sets policy on create only; after
that the console owns it.
Preorders. preorderWaitingSQL: a preorder with a release_date on or before
today's UTC date and less than AudiblePreorderWait (14 days) ago, that a backup may
wait for (not on this server, not rejected or ignored; or with a pending request
while not here). Such an account is due every hour (DueAudiblePolls) instead of
every poll_hours. Past the 14 days the title reads needs_attention: true
(preorderOverdue, the same terms; derived, no column) and the hourly clock stops.
The first poll that reports the title available queues a pending request.
On the server: the done fold
FoldAudibleImport runs from the jobs' OnEnded hook for a done audible.import:
- the result must name the job's library (and the params') and a clean book path,
else
ErrImportNotIndexed; importOwnBook: the book must be indexed in a books library (ErrImportNotIndexed) and be the title's own, elseErrImportNotOwn(logged): it carries the title's ASIN, or it holds only the files the backup placed (a single-file book is its file). A file the scan joined into a folder book already there never makes that book (nor another title's) this title's match, and never the book the admin marked Not this book for the title;- it sets the match (
match_how = 'import'), clearsrejectedandimport_pending, only whileimport_job_idis still this job.
A later poll keeps an import match while its book is indexed and the poll names no
other match for the title (importMatches): the poll may have listed the library
before the backup landed. A failed job leaves the title as it was; the console shows
its code (import.code).
Removing an account cancels its open imports (bridge.CancelAudibleImports, in the
account's delete transaction, CancelAudibleRemoved), whether the Bridge or the
console removed it.
Migration 0064_audible_import
audible_accounts.template TEXT NOT NULL DEFAULT ''audible_accounts.policy_since TEXT NOT NULL DEFAULT '', backfilledCOALESCE(last_poll_at, created_at)(an account that polled starts from its last poll, one that never did from its creation)audible_titles.import_pending INTEGER NOT NULL DEFAULT 0
No new table. policy_since joins the user-state rule's free columns of
audible_accounts (store/userstate.go), as the two poll times; a titles write is
a user-state write. The Bridge's 0010_audible_profile is
the device profile's.
Migration 0065_audible_planned
What the console says of a title the Bridge left unmatched.
audible_titles.planned TEXT NOT NULL DEFAULT '': the title's planned destination as JSON,''for none.audible_titles.contest_library_id INTEGERandcontest_path TEXT NOT NULL DEFAULT ''(NULL and''for none), with a partial index on both: the book a contested title tied for. Path-keyed with no FK, carried by a move or a join like a match's path, cleared by the library's delete.
All three are the poll's: each titles PUT writes them, and a stored match (the
poll's, a kept import match, FoldAudibleImport) clears them. A title the final
call marks gone loses its plan. The PUT checks planned strictly
(checkAudiblePlan, the rules in the
reference),
bar a path over MaxPathBytes (Audible's long text through a template), stored
as an error plan. A contest is
stored only for the account's library. GET /admin/audible/titles shows planned
only while the account is mapped to the plan's library, and contested {library_id, path, with} only while it is mapped to the contest's, with the account's other
titles (not gone) contested over that book or matched to it, at most 10
(fillContests, one query a page).
Deploy the Bridge with the server (no capability gate: see the cross-repo contract).
The template grammar
bridge.CheckAudibleTemplate is a copy of the Bridge's placement.Parse grammar
(keep audibleTemplateVars in step with placement.knownVars and templateMods
with parseMods): literal text, {Var} or {Var:mods} (mods width=N, upper,
lower, trim, comma-separated), optional groups { ... } that collapse when a
variable inside renders empty, and \ escaping the next character. Variables:
Author, Series, Title, TitleShort, Seq, PaddedSeq, SHORTCODE, ASIN,
Narrator, Year, OriginalFilename, Ext. The server also refuses over 500
bytes, a control character, a leading /, a .. segment and a hidden, dot-led
segment (the scan skips those). The Bridge parses it again when it places (and
allows up to 1024 bytes). A rendered last segment ending .m4b, .m4a or .mp4
names the file; anything else is a folder and the title's file name
(<title>.m4b, audible.safeFileName) goes in it. A template that renders no
valid path is not_writable.
On the server (0063, catalog/audible.go)
Two tables, audible_accounts (the mapping, poll_hours, last_poll_at,
last_queued_at, and 4b's template and policy_since) and audible_titles (one
row per account and ASIN, with its state and match, plus the console's and an
import's columns, rejected, ignored, dest_path, import_job_id and 4b's
import_pending, which a titles PUT never overwrites): the columns are in the
data model. ignored has no route that sets
it yet; Back up everything, the policy pass and the preorder clock skip an ignored
title.
- Paused is
library_idNULL: the library was deleted.DeleteLibrary, in the library's own transaction, clears the matches into it and cancels the paused accounts' open polls, so no titles call can match into it in between. The scheduler skips a paused account and Check now is409 account_pauseduntil the Bridge maps it again. - A title's match moves with its book: a move (
moveBookState, a scan's move detection or a Bridge rescan'sfrom) and a join (joinBookState) carry it. - Unpairing keeps the rows; deleting a user removes their accounts (the FK).
- The Bridge's writes are user-state writes (
store/userstate.go): titles and the poll times don't move the content generation; a mapping change does. - Free text is cleaned and bounded, never refused (one odd title never fails a poll): a title's authors past the first 20 and series past the first 10 are dropped. A match naming a missing or non-books library is stored as none.
The audible module
A managed module (internal/modules/builtin), off by
default (modules.audible.enabled, AUDIOSILO_MODULES_AUDIBLE_ENABLED), whose
Bridge requirement is the audible feature. Its flag on GET /server is false
(sent, never absent) unless the toggle is on and an online Bridge offers
audible. Following the podcasts module:
- the Bridge's routes are not gated: the Bridge's page signs in and maps accounts whether or not the console turned the module on (and says the module must be on for polls);
- the console's routes (
/admin/audible/accounts,/admin/audible/titles,.../poll, and 4b's backup, backup-all and the two PATCHes) answer404 module_offwhile it is off; DELETE /admin/audible/accounts/{account}is outside the toggle (admin only): an account the Bridge can no longer remove must be removable after the module is turned off. The console only shows it while the module is on, so with it off it is an API call;- queued polls and imports are held while it is off, and the scheduler and the policy pass after a poll only run while it is on.
The player doesn't read the flag and doesn't mirror it (Capabilities,
ModuleFlag): it has no Audible surface yet. An old client ignores an unknown
flag.
The console
While the module is on, Settings > Bridge gains an Audible accounts card (the
mapping read-only, title counts, Check now and Remove per account) and Settings >
Audible lists the titles with their filters; Remove removes the account from this
server only. Phase 4b (features/audible/): per title Back up (off while its
account is paused), Not this book and its undo, and Destination...
(title-actions.tsx), the import's state and code (importStatus; the loaded
titles are fetched again every 5 s while a backup is queued or running,
backupRunning), Backed up here for an import match; per account the
Placement row, Backup settings... (policy, template:
account-settings-button.tsx) and Back up everything... with its "N books, ~X
GB" confirm (backup-all-button.tsx, from backup_estimate, also at the top of the
titles page); the legal line on the titles page and in both dialogs. Each unmatched
title shows PlannedNote (plannedLine: "Will go to <path>,
beside N books of its series" and the like, "(planned at the last check)", warning
tone on exists or error, a note when the plan's template isn't the account's
now, and "Your destination is checked when the backup starts" instead while a
dest_path is set; none on an unavailable or gone title, nor on a matched
one, a Not this book title included, since a stored match clears the plan),
ReasonNote
(contested explained with the other titles and the book linked through
bookRoute, a plain sentence without data; a reason that is a machine code reads
as a sentence, reasonKey; Audible's own words stay) and Destination...'s
placeholder from the planned path, never its value (plannedPlaceholder). The Bridge's
job list names an audible.import by its ASIN, reads one with no requester as
"Scheduled by the server", and its placed files are no verify check
(resultSummary). The components are in the console's catalogue.
The Bridge's optional setup step audible (done with an account signed in, none
refused and none on an older device profile) links its Accounts tab. See the
User Guide's Audible page.
The routes
All in the API reference:
| Route | Who | What |
|---|---|---|
GET /bridge/users | bridge token | who an account may be mapped to |
PUT /bridge/audible/accounts/{account} | bridge token | record an account's mapping |
DELETE /bridge/audible/accounts/{account} | bridge token | the Bridge removed it |
PUT /bridge/audible/accounts/{account}/titles | bridge token | a page of a poll's titles |
GET /bridge/libraries/{id}/books | bridge token | a books library's books, paged, for matching |
PUT /bridge/libraries/{id}/enrichment?path= | bridge token | backfill a matched book's ASIN |
GET /admin/audible/accounts | admin, module on | the accounts with title counts |
GET /admin/audible/titles | admin, module on | the titles, filtered and keyset-paged |
POST /admin/audible/accounts/{account}/poll | admin, module on | Check now |
PATCH /admin/audible/accounts/{account} | admin, module on | an account's policy and template (4b) |
POST /admin/audible/accounts/{account}/backup-all | admin, module on | Back up everything (4b) |
PATCH /admin/audible/titles/{account}/{asin} | admin, module on | Not this book and a destination (4b) |
POST /admin/audible/titles/{account}/{asin}/backup | admin, module on | Back up one title (4b) |
DELETE /admin/audible/accounts/{account} | admin | remove an account from the server only |
audible.import itself uses no new Bridge route: it reads the library through
GET /bridge/libraries/{id}/books, indexes the placed file with
POST /bridge/rescan and sets
the ASIN with the enrichment PUT.
The Bridge mirrors the bridge-token routes by hand in internal/link/audible.go
(Users, PutAudibleAccount, DeleteAudibleAccount, PutAudibleTitles,
LibraryBooks, Enrich) and decodes nothing it doesn't need; the server decodes
accounts and titles strictly, so the Bridge sends only the documented fields.
The Audible fake
internal/audible/audiblefake exists only with the build tag audiblefake; no
release build or image carries it. It is an httptest server with synthetic fixtures
shaped from the manager's code and tests (30 made-up titles: 22 available, 4
preorders, 4 unavailable; 7 places): /ap/signin redirects to its own
/ap/maplanding with a code bound to the PKCE challenge, /auth/register checks
the verifier and client id and issues a device key, /auth/token,
/auth/deregister (this device only, recorded), signed calls verified against the
issued key (an unknown or deregistered device 401, /_fake/refuse 403),
library paging, lastpositions with a 400 over its limit (10 by default), and
PUT lastpositions recorded. Register records each device's
profile (its serial and app, os and software versions) and only the current
profile with a 40-character serial gets licenses; any other device lists its library
but every license is denied with the three throttled reasons, its customer id
quoted (to prove the redaction). POST /_fake/old-profile ages every device
registered so far. Phase 4b (content.go): GET /1.0/library/{asin};
POST /1.0/content/{asin}/licenserequest (denied for a title not owned, a preorder
not released or one without download rights; else granted, with a voucher sealed
from the device type, serial, customer id and ASIN carrying the key and IV
FakeKey(asin) gives, and an offline_url on the fake good for one license);
GET /_fake/content/{asin}.aaxc, the embedded testdata/fixture.m4b (30 s, three
chapters, retagged per title by ffmpeg when there is one) through
http.ServeContent with an ETag, so Range and If-Range work, and 403 once
expired. The fixture is an unencrypted MP4: ffmpeg 7.1 passes one through with
-audible_key/-audible_iv set (exit 0, chapters kept, audio bit-identical,
TestDecryptPassesAnUnencryptedMP4Through), so the real decrypt runs unchanged
against the fake. Its controls are GET /_fake/calls (licenses, and downloads with
their Range/If-Range), POST /_fake/refuse, GET /_fake/titles, and 4b's
POST /_fake/cut?after=N (the next download drops after N bytes),
POST /_fake/expire (the download URLs expire) and POST /_fake/release?asin= (a
preorder out today).
In a tagged build, AUDIOSILO_BRIDGE_AUDIBLE_FAKE_URL points every Audible and
Amazon endpoint at a fake, and AUDIOSILO_BRIDGE_AUDIBLE_RECORD_DIR records every
exchange into a 0600 JSON-lines file there, scrubbed as it is written
(audiblefake.Recorder: secret keys and the customer, device and token subtrees
redacted, headers from an allowlist, signed CDN queries dropped): the owner's
recording run that later, recorded fixtures will come from. See
Configuration and
Development for the tagged gate.
go run -tags audiblefake ./cmd/audiblefake --listen 127.0.0.1:8099
go build -tags embedui,audiblefake -o bin/audiosilo-bridge-fake ./cmd/audiosilo-bridge
AUDIOSILO_BRIDGE_AUDIBLE_FAKE_URL=http://127.0.0.1:8099 ./bin/audiosilo-bridge-fake --data ./data
The fake covers the shape of Audible's answers; only a real account proves them.