Use of AI: AudioSilo is developed by me (a human), with assistance from AI, primarily Claude Code, to help me write, clean up, document, and review the code. That doesn't mean the app is generated on autopilot or "vibe coded". Nothing goes out until I've read it, tested it, and decided it belongs. AI is what lets one person keep up this pace, and I think it's important to disclose that.
Website · Metadata · Docs · Discord · Sponsor
The open source community audiobook database behind meta.audiosilo.app - audiobook metadata, in the open.
The GitHub repository is the database. Metadata lives as plain JSON pack files - many records to a file, addressed by slug range (PACK-SPEC.md) - edited by pull request or issue form, validated by Go tooling in CI, and compiled into a single SQLite artifact that servers consume.
Status: Phase 1 shipped, Phase 2 landed. Governance, schemas, the validation/build pipeline, the OpenAudible and Libation importers, the read-only API server (
metaserve), the website (site/, served by metaserve at meta.audiosilo.app), the Docker image, the in-browser/importdiff, the Audiobookshelf metadata-provider endpoint, and issue-form intake automation are all built. The characters/recaps CC BY-SA layer (Phase 2) is live across the schema, tooling, and data - and since 2026-08-21 it lives in its own repository, audiosilo-meta-community, which the release build composes into the same artifact. Still open: Open Library / Wikidata crosswalk seeding. Only claim what exists.
No existing open database is both audiobook-specific and community-editable:
- Open Library, Wikidata and Inventaire are open (CC0) but have essentially no audiobook structure - no narrator field of substance, no chapters, no runtime.
- BookBrainz has no audiobook format and no narrator field.
- Audnexus (the backend Audiobookshelf and Plex read from) is a read-only cache over Audible's own catalogue; its own README says it exists only "during the interim of waiting for a community driven audiobook database."
So narrators, specific recordings, and chapters are treated here as first-class data - the fields every other database lacks, and the reason this project exists.
contributor
│ pull request / issue form
▼
data/*.json ──► CI validation ──► meta.sqlite ──► API server + site
(the database) (metacheck + (release (metaserve,
metafmt, Go) artifact) read-only)
- Contributors add or edit JSON files in
data/(by pull request, or via issue forms that become pull requests). - CI validates every pull request - schema, canonical formatting, referential integrity, and identifier uniqueness. A red pull request never merges.
- On merge to
main, a release workflow buildsmeta.sqliteand attaches it (gzipped, with a checksum) to a dated GitHub Release. - Servers download that artifact and serve a read-only API. All writes go through GitHub; there are no server-side accounts.
- Work - the abstract book (title, authors, language, series membership).
- Recording - a specific narration of a work (narrators, abridged flag,
runtime, release date, publisher, region-scoped ASINs, ISBNs, cover URL,
chapters). One work, many recordings. A production released in several
marketplaces stays one recording: an ISBN may be written as
{"isbn": ..., "region": ...}when the region is known, andpublishers[]records the other regions' imprints beside the publisher of record. A LibriVox recording also carries its project id (librivox_id), its archive.org item (archive_id),links(catalogue page, zip, RSS) and anaudio_urlper chapter (one per LibriVox section). - Person - a human shared across roles (author on works, narrator on recordings; can be both).
- Series - a named series with an ordered list of member works (string
positions such as
"2.5").
Harry Potter and the Philosopher's Stone is one work with two recordings - Stephen Fry and Jim Dale - each carrying its own ASINs. That is the shape this project is built around. Full details in CONTRIBUTING.md.
data/ the CC0 database: works/, people/, series/ + redirects.json (range-packed JSON, see PACK-SPEC.md)
(the CC BY-SA characters/recaps layer lives in KodeStar/audiosilo-meta-community)
schema/ JSON Schemas (one per entity) - authoritative field definitions
cmd/ Go tooling: metacheck (validate), metafmt (canonicalise), metabuild (SQLite), metaserve (API server)
pkg/ public Go packages: the data model, pack storage and checks, and the read side
(query: the artifact's queries and the JSON API handler; release: the data-release fetcher)
internal/ shared Go packages behind the tooling (build, serve, importer, ...)
Dockerfile container image: API server + static site (no baked data)
.github/ issue forms + CI workflows (check, release, image)
CONTRIBUTING.md GOVERNANCE.md LICENSING.md
Requires Go 1.26+ (pure Go, no cgo, no external services).
git clone https://github.com/kodestar/audiosilo-meta
cd audiosilo-meta
# The gate - run before opening a pull request:
go build ./... && go vet ./... && go test ./... && \
go run ./cmd/metacheck --profile core && go run ./cmd/metafmt --check --profile core
# Build the CORE-ONLY SQLite artifact locally (validates first). The REAL
# artifact composes both repositories - add --community <community-checkout>/data:
go run ./cmd/metabuild -o meta.sqlite--profile coreis what CI passes, and what this repository's tree is: works, people, series. Without it the tools read the root as a whole database and quietly adopt a straydata/works-community/file that CI reports as an unrecognized location - so a green local run would not mean a green PR.metacheck- schema, referential integrity, and uniqueness validation.metafmt --check/--write- canonical JSON (sorted keys, 2-space indent, trailing newline) plus pack placement:--writemoves an entry to the pack its slug belongs in and splits a pack that has outgrown its caps, so nobody has to work placement out by hand.metabuild -o meta.sqlite- compile the database into a SQLite file (--community <dir>composes the community checkout in; without it the artifact is the core half only). A work'sadded_atcomes from the record; one that carries none falls back to the newestsources[].imported_at.metamigrate- the one-off conversion of a pre-pack, file-per-record tree into the pack layout (--out <dir>to rehearse without touching the source).
metaserve is a JSON API over the compiled artifact, which it only ever reads - FTS
search, work/person/series detail, chapter lists, ASIN/ISBN/LibriVox lookup and a
LibriVox-shaped feed. All data is public, so reading it needs no auth; the API sends
permissive CORS, is rate limited per client and states a Cache-Control.
The query code and the API handler are the public Go package pkg/query
(query.Open + query.NewHandler), and the release fetcher is pkg/release, so
another program can serve the same API in-process from a downloaded artifact -
which is what an AudioSilo server's metadata mirror mode does. Because such a
consumer can read an artifact newer than its own code, artifact schema changes
stay additive (new tables and columns only).
# Dev: build the artifact, then serve it locally.
go run ./cmd/metabuild -o meta.sqlite
go run ./cmd/metaserve --db meta.sqlite --addr :8080
curl localhost:8080/healthz
curl 'localhost:8080/api/v1/search?q=dragon'
curl 'localhost:8080/api/v1/works/latest'
curl 'localhost:8080/api/v1/lookup?asin=B08G9PRS1K'Key endpoints (all under /api/v1): stats, search?q=&limit=,
works/latest?limit=, works/{id}, works/{id}/recordings/{rid}/chapters,
people/{id}, series/{id}, lookup?asin=|isbn=|librivox_id=, coverage,
coverage/works, coverage/series-gaps, librivox/audiobooks, plus /healthz.
The LibriVox layer (artifact schema version 8). search, works/search and
works/latest take ?source=librivox (any source type of the schema), which
keeps only works with a recording from that source - a typo is a 400, never an
empty list. Filtered, works/latest orders by the newest recording from that
source rather than by the work, so a new LibriVox reading of an old classic is
new. A LibriVox recording on works/{id} carries librivox_id,
archive_id, links (librivox, zip, rss, text) and copyright_year,
leads its purchase_links with a free librivox link, and its chapters carry
audio_url. lookup?librivox_id=52 resolves a project id.
librivox/audiobooks (and librivox/audiobooks/) answers LibriVox's own
audiobooks feed - id, since, title, author, genre (exact, or a prefix
with a leading ^), extended, coverart, fields, limit (default 50, at
most 500), offset, sort_order, JSON only - in LibriVox's JSON shape, so a
LibriVox app can switch by changing its base URL. It differs where the mirror
keeps no fact (empty descriptions, our genre vocabulary, a reader named only for
a one-narrator recording, since meaning "reached the mirror since"); the API
docs list each difference. Anyone wanting the whole catalogue should download
meta.sqlite.gz from the newest data release rather than page the feed.
Work-detail recordings also carry purchase_links - non-affiliate Audible and
Libro.fm URLs derived at response time from recording ASINs and 13-digit
recording ISBNs, with availability deliberately unknown (see the API docs).
The work page renders them: the server-rendered fact sheet lists every link, and
the island leads with the marketplace the reader's browser languages suggest,
keeping every other recorded region one click away.
people/{id} and series/{id} take an optional ?limit=&offset= window and
report the unpaged totals (authored_total/narrated_total, works_total).
They differ in default: a person's credit lists are paged (100 per page, 500
max) because they are unbounded - a corporate credit such as "Full Cast"
narrates thousands of works - while a series returns every member work unless a
window is asked for, because the player's series rail is composed from the full
list.
The coverage endpoints back the site's contribute page. coverage returns the
top-line band only - how many works carry characters/recaps/whole-book recap
summaries - so it stays tiny at any catalogue size. coverage/works?filter=&q= &limit=&offset= is the paginated, searchable browser: filter is missing
(missing any dimension) or has_characters/has_recaps/has_recap_summary; q
matches a work's title, authors, narrators and series through the same full-text
index as search (whole words and word prefixes, not mid-word substrings); the
response carries a per-filter
available flag that is
false when the dimension is not evaluable at the artifact's schema version.
coverage/series-gaps?q=&limit=&offset= is the paginated, name-searchable list
of series with interior position gaps.
Flags: --db (local artifact), --site <dir> (serve a static site at /),
--poll (fetch and hot-swap the latest published data release from GitHub),
--repo (default KodeStar/audiosilo-meta), --interval (default 1h),
--cache (download dir). With --poll and no --db - what the container image
runs - the server fetches the newest data release (the newest release carrying
meta.sqlite.gz; code/image v* releases are skipped) on boot, streaming it to
the cache directory and verifying the published checksum before it counts. If
that first fetch fails the process does not exit. It first falls back to the
newest artifact already in the cache directory - what the previous container was
serving - and serves that, logged as stale, until a release loads. Only with
nothing cached does it come up empty (static site served, /healthz and every
API route 503 with a clear message, and a Retry-After reporting the wait
actually in force). Either way it retries after 30s and then backs off, doubling
up to --interval, so an unreachable GitHub is a logged outage rather than a
container crash loop. A restart while GitHub is reachable does not re-download
what the cache already holds either: the cached artifact for the newest release
is verified against that release's published checksum and adopted as is. With
--db as well, the local artifact serves immediately
and the poller still runs a refresh at startup (not just once per --interval).
Superseded artifacts are pruned from the cache after each hot-swap. Set
GITHUB_TOKEN to raise the API rate limit.
Rate limit and caching: every API route (/api/v1/* and /abs/search, not
/healthz) wears a per-client token bucket - --rate-limit requests a second
(env METASERVE_RATE_LIMIT, default 100, 0 turns it off) with a burst of
--rate-burst (env METASERVE_RATE_BURST, default 400; at least 1 while the
limit is on - a burst of 0, or a negative rate, is refused at startup rather
than defaulted). The defaults are sized so an AudioSilo server's bulk match,
which does not pace itself, never trips them (the arithmetic is in
internal/serve/ratelimit.go). Past it a client gets
429 with Retry-After. The client is the connection's address, or, when that
address is one of --trusted-proxy (env METASERVE_TRUSTED_PROXIES, a
comma-separated list of addresses and CIDR prefixes), the rightmost
X-Forwarded-For entry that is not itself a trusted proxy (an IPv6 client
counts as its /64). Behind a reverse
proxy, name it there, or every request counts as the proxy and shares one
bucket; a forwarded header from anywhere else is never read. The shipped
docker-compose.yml trusts only the loopback and its own network's pinned
subnet (METASERVE_SUBNET, default 10.217.53.0/28) - in practice that network's
gateway, which is where a host proxy on the loopback-only port arrives from. That
is right for a host proxy; a containerised proxy on another Docker network must
add its own address to METASERVE_TRUSTED_PROXIES, or every visitor shares one
bucket. Never trust the private ranges wholesale: a LAN client of the proxy could
then choose its own bucket. An existing deployment's first start after the subnet
was pinned needs docker compose down && docker compose up -d once. A 200 that states
no Cache-Control of its own gets public, max-age=600, or public, max-age=60 on the searches and works/match.
For immediate production refreshes, set METASERVE_WEBHOOK_SECRET to a random
value of at least 32 bytes while keeping --poll enabled. This registers
POST /hooks/github/release, authenticated with the standard
X-Hub-Signature-256: sha256=... HMAC header. The release workflow calls the
endpoint only after every release asset has uploaded, then metaserve re-queries
GitHub, verifies the published checksums, and uses the same atomic hot-swap path
as polling. The webhook never trusts or installs data from its request body.
Hourly polling stays enabled as a fallback for a missed delivery.
Configure the deployment and GitHub repository with the same secret:
- Generate a secret, for example with
openssl rand -hex 32, and expose it to the container asMETASERVE_WEBHOOK_SECRET. - Add the Actions secret
METASERVE_WEBHOOK_SECRETwith that value. - Add the Actions secret
METASERVE_WEBHOOK_URLwith the public endpoint, for examplehttps://meta.audiosilo.app/hooks/github/release.
The endpoint is not registered when the deployment secret is absent. A missing workflow configuration or failed delivery is non-fatal because the fallback poller will still discover the release.
A deployment can also offer the unknown-ASIN intake queue, which is off unless
configured. Set METASERVE_INTAKE_DB to a path for its SQLite file (in the shipped
compose file, /data/intake/queue.sqlite sits on the volume, outside the artifact
cache)
to register POST /api/v1/intake/unknown-asins, and METASERVE_INTAKE_TOKEN to a
random value of at least 32 bytes (openssl rand -hex 32) to register the bearer
token GET on the same path as well. The file holds each reported ASIN with a
count and its first and last UTC day, and nothing about who reported it; the POST's
limit (6 a day per client) holds a client's address in memory until about a day
after its last request, refused or not. That covers metaserve alone: a reverse proxy in front logs
what it is set to log, so check its access log for this route. The POST needs
METASERVE_TRUSTED_PROXIES right behind a proxy
even more than the API does, or every reporting server shares one 6-a-day bucket.
A queue file that cannot be opened is logged and leaves the routes off. Losing the
file is harmless, because servers report again. Unset the variable to turn it off:
the routes answer 404 and the spec no longer lists them.
The image bundles the server and the static site (built from site/) - and no
data. On boot it downloads the newest published data release into the cache
volume, then accepts signed release refreshes when configured and polls as a
fallback. Keeping the catalogue out of the image means a site or server change
neither rebuilds nor ships the database, and the image size is independent of
how large the catalogue grows.
docker build -t audiosilo-meta .
docker run -p 8080:8080 -v audiosilo-meta-cache:/data ghcr.io/kodestar/audiosilo-meta:latestFor a real deployment use the committed docker-compose.yml:
loopback-only port (a TLS reverse proxy such as nginx/Ploi fronts it), a named
volume for the release-download cache, an optional signed release webhook, and
hourly fallback polling. The image entrypoint carries all required flags;
command: appends extras (for example command: ["--interval", "15m"]).
Health check endpoint: /healthz - a readiness check, so it answers 503
{"status":"starting"} until the first artifact has loaded.
The image workflow builds and pushes ghcr.io/kodestar/audiosilo-meta on a
v* tag.
metascan is the low-friction way to contribute a library when you have only
audio files - no OpenAudible or Libation export. Point it at a folder and it
walks the tree, gathers whatever metadata it can find, and writes a JSON file the
meta.audiosilo.app import page accepts.
go run github.com/kodestar/audiosilo-meta/cmd/metascan@main /path/to/audiobooks -o scan.jsonThen drop scan.json onto meta.audiosilo.app/import.
It runs entirely on your machine and sends nothing anywhere - it only reads files. What it gathers, per book:
- Embedded tags (via dhowden/tag): title, authors, narrators, and an ASIN when Libation/OpenAudible embedded one.
- The folder structure, treated as a first-class source (tags are often
missing or wrong, series data especially):
Author/Book,Author/Series/Book, and name patterns like01 - Title,Book 3 - Title,Title, Book 3, andJack Reacher 03 - Titleyield author/series/position/ title. - An ASIN - the anchor that makes a sparse book matchable - hunted in tag
atoms and in file/folder names (for example
Title [B076HYPQLK]). - Runtime and chapter counts, if
ffprobeis on yourPATH. Without it the scan still works; those two fields are simply omitted.
Have ffprobe installed if you can: it is also the deeper tag reader. The pure
Go reader covers the common title/author/narrator tags plus MP3 user frames,
but several audiobook-specific fields are reachable only through ffprobe -
Audible/Libation freeform MP4 atoms (ASIN and friends), m4b stream language,
and various container extras. Without ffprobe, embedded series/ASIN extraction
is limited (especially for m4b files); the folder-structure heuristics still
work in full.
Every field records where it came from (tag / path / filename) in the
book's sources map, and unknown fields are omitted rather than guessed.
Grouping follows the workspace convention: a folder that directly contains audio
is one book (its files are the parts), and loose files at the scan root are
individual single-file books. One evidence-gated exception: when a folder's
files carry mutually distinct album tags (or distinct, non-generic title tags
that each match their own filename), that is a flat folder of separate
single-file books - the common Series/01 - A.m4b, 02 - B.m4b layout - and each
file becomes its own book, with the folder feeding the series/author heuristics.
Without tag evidence the folder is always kept as one book (never split on
filenames alone), and a multi-file folder with no signal either way is counted
in the summary so you know where to check for collections. The JSON goes to
stdout by default (or -o <file>); a human-readable summary goes to stderr.
Pass -ffprobe "" to skip ffprobe enrichment.
LibriVox's catalogue is mirrored here under LICENSING.md's public-domain exception - facts only, never the project descriptions. It is two steps:
# 1) Fetch a local snapshot of LibriVox's audiobooks feed. Polite by design:
# 100 projects a page, 5 s between requests, a User-Agent naming
# meta.audiosilo.app, Retry-After honoured on 429/5xx. Resumable: rerun the
# same command after an interrupt. A response body over 64 MiB is no feed
# page: it stops the run, never cut off. The snapshot is never committed.
go run ./cmd/metaimport librivox fetch --out ~/librivox-snapshot [--max-pages N]
# 2) Import a tranche of it by project-id range (offline; --dry-run first).
go run ./cmd/metaimport librivox import --from ~/librivox-snapshot --ids 1-2000 --dry-runA snapshot is a directory: manifest.json (the fetch's resume state: feed,
query, limit, next offset, page and project counts, complete or not) and
pages/offset-NNNNNNN.json, each one feed response body verbatim. The importer
reads pages/*.json and needs nothing else, so a hand-assembled snapshot (the
test fixtures are one) works too. The fetcher records each page's range of
project ids and its size in the manifest (page_ids), and a tranche skips
every page whose range misses its --ids (0.19 s instead of 2.0 s for a
2,000-project tranche of the 22,449-project snapshot). A page the manifest does
not range (fetched before the ranges were recorded, a hand-assembled one) or
whose size has changed since is always decoded, and a decoded page holding an
id outside its range reads the snapshot whole. A project whose id is missing or
unreadable is skipped with a warning naming its page and title (no --ids
could select it).
What an import does: a project number LibriVox retired or holds for reuse (no
author, titled "DO NOT RE-USE PROJECT NUMBER" or "REUSE project number - ...")
is no book and is only counted (librivox: set aside N retired or reusable project numbers). A project LibriVox is still recording (no totaltimesecs
and no archive.org item in url_iarchive; the feed has no status field) is set
aside before anything else and writes nothing, listed on one line of the report
(librivox: skipped N in-progress projects ...: <ids>), so re-importing its id
once LibriVox completes it lands it like any other; one the catalogue already
holds is counted as already present instead. A project whose
librivox_id is already catalogued is skipped (identity is the project id,
never the title), so a rerun changes nothing. Otherwise it becomes a new
recording - never merged into another recording - under the work these rules find, in order (LibriVox's repeat markers are
stripped from the title first: a trailing "(version 2)", "(ver 2)", "(ver. 2)"
or "(Dramatic Reading)", stacked or combined in one parenthetical, or a bare
"Version 2" ("Walden, Version 2", "Sea Wolf - Version 2"), "version" also as
Italian "versione", Spanish "versión" or Portuguese "versão", so a dramatic
reading or a numbered version is another recording of the plain-titled work; a
version number has one or two digits, so "(Version 1611)" is a year and stays;
and a version marker ending a parenthetical that names something else loses
only the marker: "Spring (Barber version 2)" is "Spring (Barber)", "Alice's
Adventures in Wonderland (abridged, version 2)" is "... (abridged)", an
abridged recording as "(abridged)" always is; a translation named "...
Version" keeps the word, so "(King James Version 2)" is "(King James
Version)"):
- the importer's ordinary identity walk: same title, same identity authors,
same language. An author or translator whose name slugs to nothing (a
Cyrillic "Йордан Йовков") is never the one catch-all person record every
such name would share: with a LibriVox author id it is a person of their own
at
librivox-author-<author id>(librivox-author-17538, the name kept as written); with none, the credit is dropped before the work is decided and listed undercredits not recorded(unsluggable), and a project left with no individual author (only a translator, or only a collective such as Anonymous) goes to the worklist asauthor-unsluggable; - otherwise the ONE work by the same authors, in the same language, whose title
differs only by a leading article or punctuation, case and accents ("The
Metamorphosis" and "Metamorphosis" by Franz Kafka), or by how a volume is
spelled ("Vol. 1 of 3" and "Volume 1"; two titles stating different totals,
"Vol. 1 of 3" and "Vol. 1 of 5", are two volume sets and never match, and a
work titled "Volume 1" that already holds a "Vol. 1 of 3" recording is the
three-volume set: "Vol. 1 of 5" goes to the worklist as
volume-total). The articles set aside are the English the/a/an, French le/la/les/l', German der/die/das, Spanish el/los/las, Italian il/lo and Dutch de/het/een. Failing that, the ONE such work by an author the project spells with more or fewer words (the same surname, one name's words a subset of the other's: "Henrietta Elizabeth Marshall" and "Henrietta Marshall") is attached too, the project's credit moved onto the catalogued person unless LibriVox's spelling is already a person record of its own; a looser likeness (an initial, another given name, a one-word name) is the worklist'sauthor-spelling; - otherwise, when the title with its trailing parenthetical or bracketed
qualifiers removed is a work's title under rule 2 ("Frankenstein (1818)",
"Black Beauty (The Autobiography of a Horse)" by the same author), the
project goes to the worklist as
differently-spelled-title(several-workswhen more than one work matches), never attached: the qualifier can as well be a volume ("King in Yellow (part 1)"); - otherwise a new work. A work by a different author under the same title is a different book, so it gets a work of its own.
A project that attaches to a work already in the catalogue adds its recording
(and its readers) only: a create run never edits a catalogued work's credits, so
an author or translator that work does not name is not recorded there, and no
person record is created for them (it would be credited nowhere). The import
report lists each such credit (project id, name, role, librivox_author_id,
work) under credits not recorded, for recording-level credits to pick up
later (meta#2752). A row of any other source (OpenAudible, Libation, AudioSilo
books, libex) that attaches to a catalogued work, or is relocated onto one,
follows the same rule, its credits listed there by ASIN.
No import ever credits the shared catch-all person. libex refuses a row naming
anyone whose name slugs to nothing; a user-library import (OpenAudible,
Libation, AudioSilo books) keeps the book and leaves that author, contributor
or narrator off it, listing each under credits not recorded by ASIN with the
reason unsluggable (also in a library-import issue's intake notes). A book
left with no individual author is skipped as missing-author, and one left
with no narrator as missing-narrator, each with a warning naming the names;
the library-import intake verdict says how many books were skipped this way
(they need a maintainer) rather than suggesting the file is the wrong format.
A match the rules cannot settle is never guessed: the project is skipped and listed in the import report's worklist, one line per project with its id, title, authors, a reason code and the catalogued works involved:
librivox worklist: 2 projects skipped for a person to resolve (re-import the ids once settled):
99502 "The Metamorphosis" by Franz Kafka [several-works]: metamorphosis "Metamorphosis"; metamorphosis-2 "Metamorphosis"
99503 "Metamorphosis: A Novella" by Franz Kafka [differently-spelled-title]: metamorphosis "Metamorphosis"
The reasons: several-works (two or more works qualify under rule 2),
author-unmatched (a work carries the title but its authors only partly agree),
author-spelling (a work carries the title by an author of the same surname
whose name only loosely agrees with the project's, or beside other
candidates), author-unsluggable (every individual author's name slugs to
nothing and carries no LibriVox author id), author-slot-taken (as
author-unsluggable, but the author's librivox-author-<id> slot is another
person's or a retired one; the run warns, and a co-author's credit is listed
not recorded as librivox-author-slot-taken), differently-spelled-title (the duplicate-identity guard holds
the book under a title rule 2 does not read as the same, such as a subtitle
apart, or rule 3's trailing qualifier), volume-total (rule 2's work holds a
recording stating another volume total), same-name-author (below) and fallback-slug-taken (below). Once a person has settled the work,
re-importing the project's id lands it. --worklist <path> appends the
worklist to a file as well, one NDJSON row per project (librivox_id, title,
authors, reason, candidates), on a --dry-run too, as --conflicts
does. The answer to a row is a reviewed decision in
internal/importer/librivoxreviewed.json, one entry per project with its reason:
attach lands the recording on the named work whatever the title and author
rules say (with moves, row person to work person, to carry a loose spelling's
LibriVox id onto the catalogued person), and own-work makes it a book of its
own (a separately named translation, a volume of a set, a stated edition, another
author's book of the name), minted on its own title chain past the
duplicate-identity guard. Each states the project's title, since LibriVox reuses
project numbers. It is an input of the importer, not a hand edit, so rebuilding the
mirror reaches the same work (attach rows are planned after every other row, so an
attach can name a work a later project creates; its moves key the author id for the
whole batch). A decision that cannot apply never
falls through to the rules: an attach naming no live work is the worklist's
reviewed-work-gone, one naming a work in another language reviewed-other-language,
an own-work whose walk reaches a work that is not a home of its text
reviewed-own-work-taken (it may be the one it was ruled apart from), and a project whose title's key has changed is
reviewed-title-changed. A row refused before routing (same-name-author,
author-unsluggable, author-slot-taken) keeps that refusal and the run warns that
it shadows the decision; a same-name-author hold is settled on the person: put the
librivox_author_id on the right record. An own-work beside its twin does not make
their key ambiguous to a refusal: the duplicate-identity guard of every source, and the
intake gate, still answer the twin (check.SetApart). --attach-editions and a
relocation, which place the recording, still read the two as ambiguous. --conflicts rows for these projects
carry librivox_id, since they have no ASIN, and so do the --skipped lines
(one {"librivox_id","reason"} NDJSON line per project refused under a
refusal code: an unknown language, no narrator, author or title, or the
duplicate-identity guard's identity-duplicate; the libex --skipped shape
with the project id in place of the ASIN).
Readers are the narrators, translators are translator credits, sections are
chapters (start_ms cumulative from each playtime, the section's file as
audio_url), the language name maps to a code (an unknown language is a
reported skip, never a guess) and genres map onto the vocabulary where one fits.
A non-Latin title - one that slugs to nothing, or one where more than half
of its letters are not Latin script (digits, punctuation and spaces are not
counted; the leading article is dropped first) - never walks a slug chain,
which would file it under the shared "untitled" fallback or under its Latin or
number fragment ("Сборник стихов 001" as "001"). It is matched by rule 2 alone
(its key keeps its own letters, so a second project of the same title by the
same author attaches, and "The Война и мир" meets "Война и мир"), and otherwise
its new work is created at librivox-<project id>, titled as LibriVox writes
it (fallback-slug-taken when that slug is already used). A book from any
other source (libex, a user library) with the same non-Latin title by the same
authors in a compatible language finds that work by the same key, rather than
minting a duplicate under its Latin fragment or "untitled". A title exactly half
Latin ("Anna Karenina (Анна Каренина)") stays on the slug rule.
People are keyed by librivox_reader_id / librivox_author_id: a known id
resolves to its catalogued person whatever the feed now calls them. A reader
whose name slugs to nothing (Greek, Cyrillic, CJK) is a person of their own at
librivox-reader-<reader id> (librivox-reader-11294), the name kept as
written, so two such readers are never one record and a rerun finds them by
id; so is an author or translator with a LibriVox author id, at
librivox-author-<author id> (above); one with no id stays a credit not
recorded. A project whose reader slugs to nothing and cannot be filed there (no
reader id, or the slot is another person's or a retired one) is skipped with a
warning, never credited to the catch-all. metacheck refuses the shared
catch-all person, and any work or recording crediting it, whatever the source.
A reader id is a person of its own even when the name is not: when the slug of
a reader's name is already held by a person carrying a different reader id
(LibriVox has several accounts called "Martina"), the reader is created at
<name>-librivox-<reader id> (martina-librivox-634), and the first holder of
the name (by project id, whatever order the import reads them in) keeps the
plain slug. Re-importing a project the catalogue already holds
moves a reader folded onto another reader's person (by an import before this
rule) onto their own; authors are not split. Nor is a reader stamped onto an
AUTHOR record (one carrying a librivox_author_id, or whose life ended before
2005, when LibriVox began - a birth or flourished year alone counts: no one
born in 1840, or who flourished in 1807, read for LibriVox - in whichever order
the run meets the two): a reader called Adam
Smith is adam-smith-librivox-19341, never Adam Smith (1723-1790). A living or
undated author of the same import and a reader of the same name are one person,
whichever project comes first.
An author id no person carries yet is checked against the person its name meets before it is credited there. The decision is taken once per author id, for the whole import and before any project is placed, against the person the import would actually credit (after its spelling rules, initials included), and every project of the id then credits that person: the result never depends on the order the projects are read in.
- Courtesy titles. "Mrs. William Platt" is never folded onto "William
Platt" when LibriVox lists them under two author ids in one import, whatever
their years: both records are kept, and the import report lists the pair
(
librivox worklist: N author id pairs one courtesy title apart) for a person to merge or confirm, since LibriVox also keeps duplicate records of one author ("Mrs. C. M. Livingston" and "C. M. Livingston"). And "Sir Hugh Fraser" is not folded onto a catalogued Hugh Fraser who only narrates (when the dates cannot tell, the project goes to the worklist as below). A catalogued id holder keeps its own spelling. A bare name that would be a new person lands on the one catalogued record with a courtesy title or a military rank in front of it ("Frederick Treves" on Sir Frederick Treves, "Joseph H. Alexander" on Colonel Joseph H. Alexander) unless their years disagree or that record only narrates. - Same-name authors. When the catalogued person is someone else (another
librivox_author_idwith other years; years that disagree, a flourished year outside the person's life included; or, with neither, a person credited only as a narrator who is not a LibriVox reader meeting an undated author, or only on works first published more than 25 years after the author's death - or, when LibriVox states only a birth year, after a hundred-year life from it - and sharing no title with the project; a generic title such as "Poems" or "Selected Essays" is not a shared title, though "Poems (Ballads of a Cheechako)" is), the author is a person of their own at the name with their years:james-grant-1822-1887("James Grant (1822-1887)"),richard-wilson-fl-1916("Richard Wilson (fl. 1916)"). With no year to tell them apart, the author is a person of their own at<name>-librivox-author-<author id>(ralph-scott-librivox-author-16428, beside a retail narrator called Ralph Scott), and the import report lists the pair (librivox worklist: N author ids filed at <name>-librivox-author-<id>) to merge or confirm, since LibriVox may list one person under two ids; a collective such as "Anonymous" is never split this way. When the catalogued person's works are dated only by their recordings' release dates (a modern book and a modern edition of an old one look alike there), the project goes to the worklist assame-name-author, naming that person's works. Settling it means putting thelibrivox_author_idon the right person (the catalogued one, or a new dated record); re-importing the id then finds that person by id. A work is dated by its text: its first publication, or the copyright year a LibriVox recording of it states. And release dates stop counting against the author when a project they wrote (not one they translated, nor an anthology of more than two authors) shows they are editions: the catalogue holds that text, under its full title (never a generic one such as "Poems"), by a co-author the project also credits (that very person, not merely a shared surname), dated only by a release long after the author died ("Narrative of Sojourner Truth", 1850, beside Olive Gilbert's edition of 2009). Two new author ids of one name in a single import are told apart the same way: the second is the first's person only when both state the same birth and death years, else dated or at its author-id form, as a later import would decide it. - One person under two LibriVox ids. LibriVox lists some people twice: two
author records (F. P. Walter 6492 and 6858), or two reader accounts (Branko
Collin 778 and 1775). An import never records the second id: a reader's is
filed beside the first, an author's is told apart by the rules above (the same
years land the credit on the first's record, the id stays unrecorded; no year
files it beside them, on the report's "beside a same-name person" list). Once
the evidence says one person (the same text, the same years, the same site on
both reader pages), the fold moves the credits, retires the twin's slug in
data/redirects.jsonand records the second id inlibrivox_author_aliasesorlibrivox_reader_aliaseson the surviving person. An import resolves an alias as it does the record's own id, so the twin is not minted again, and metacheck refuses a retired slug of a LibriVox id form whose target does not answer to that id. Evidence of two people is a reviewedmerge-peoplerejection ininternal/audit/reviewed.jsoninstead. No run writes either.
metaserve doubles as an Audiobookshelf custom metadata provider. An ABS
admin adds it under Settings -> Item Metadata Utils -> Custom Metadata
Providers with the URL https://meta.audiosilo.app/abs and no authentication
(the data is public); it needs ABS v2.8.0 or newer. Once added it appears in
each book's Match / Quick Match picker (it is not used by the background
scanner), for book libraries only.
The endpoint is GET /abs/search. ABS sends ?mediaType=book&query=<title>
(with optional &author= and &isbn=) and never an ASIN; the server returns
{"matches": [...]} with one entry per recording, up to 10 - an exact ISBN
lookup first, otherwise an FTS title search with loose author boosting. Each
match carries title, subtitle, author, narrator, publisher, publishedYear,
description, cover, ISBN, ASIN, series + sequence, language, duration (in
minutes), and genres when the work carries them (values from the project's own
normalized genre vocabulary - never a retailer's category tree). Tags are
deliberately never returned. The /audiobookshelf
site page walks through both directions (adding the provider, and exporting an
ABS library into /import).
New contributions are welcome - by direct pull request or by issue form (no JSON required). Start with CONTRIBUTING.md; the merge policy and trust tiers are in GOVERNANCE.md.
Data rules in brief: factual fields only, no publisher blurbs, covers as URLs,
own-words descriptions, sources[] on every record, and a CC0 dedication for
every submission.
| What | Licence |
|---|---|
| Code (tooling, schemas, CI, future server) | AGPL-3.0-only (LICENSE) |
| Data - factual core (works, recordings, people, series) | CC0-1.0 public domain dedication |
| Data - derived layer (characters and recaps) | CC BY-SA 4.0, in its own repository: audiosilo-meta-community |
This repository holds no share-alike content. Since the community-repo split
the CC BY-SA layer is a separate repository; the release artifact is composed
from both, so meta.sqlite still carries characters and recaps and each record
states its own licence.
Publisher blurbs and cover art are referenced, never copied. Full policy, including the takedown / rightsholder opt-out channel, in LICENSING.md.
- Phase 0 (done) - governance, schemas, CI validation, the SQLite builder, and hand-curated records that prove the pipeline (including multi-recording works).
- Phase 1 - the read-only Go API server (
metaserve, FTS search + ASIN/ISBN lookup), the website (site/- search-first landing, stats, latest additions, work/person/series pages), the Docker image, the OpenAudible and Libation importers, the public deployment (meta.audiosilo.app), the in-browser import page (/import- parses an OpenAudible export, a Libation export, an Audiobookshelf library export, or a metascan folder scan client-side and diffs it against the live catalogue), and issue-form intake automation (a data issue form becomes a validated bot pull request - see GOVERNANCE.md) have landed. Still planned: seeding from Open Library / Wikidata identifier crosswalks. - Phase 1.5 - AudioSilo player integration - the AudioSilo server
(
GET /libraries/{id}/meta) and player surface enriched metadata from this database, capability-gated and behind an admin off-switch. This priority integration has landed, and the Audiobookshelf metadata-provider facade (GET /abs/search, see above) has now shipped on top of it. - Phase 2 - community-authored characters and recaps under strict length and
originality rules, in a separately-tagged CC BY-SA layer, have landed: the
schema enforces the layer structurally,
metacheckvalidates it, andmetabuild/metaserveship it. The layer itself now lives in audiosilo-meta-community, which is also where its issue forms and its authoring guide are; the release artifact is composed from the two repositories. - Phase 3+ - the source-to-sidecar extraction tooling (
metaextractplus the documented agent process) has landed; deeper player rendering gated by the listener's progress position is in progress.
Community: questions, contribution help, and coordination happen on the AudioSilo Discord and in GitHub Discussions.
Design basis: the workspace feasibility study (../META-FEASIBILITY.md).
