Skip to content
25 changes: 20 additions & 5 deletions docs-developers/architecture/cross-repo-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -760,8 +760,10 @@ player.
**Server:**

- `internal/meta` resolves a book's ASIN or ISBN against `metaserve` and composes an
enrichment envelope. `GET /libraries/{id}/meta` returns it, behind an admin
off-switch and a bounded cache.
enrichment envelope. A book with neither, or one `metaserve` does not know, is
resolved by its recorded match instead (see
[Matches by work id](#matches-by-work-id)). `GET /libraries/{id}/meta` returns
it, behind an admin off-switch and a bounded cache.
- `GET /meta/work?id=…` serves one work document straight through, from the same
cache, for the earlier books of a series the caller does not own. It is authed but
**not** library-scoped, since a work id names nothing on this server.
Expand Down Expand Up @@ -864,9 +866,18 @@ From 2.6.0 the server records each match by its work id (`book_matches`). So a s
listing that `metaserve` holds with no ASIN or ISBN counts as matched. A refresh
looks such a book up by `works/{id}`, following a retired slug's `301`. The rule a
run applies to such a book is in
[Bulk community matching](../server/api/reference.md#bulk-community-matching). The
player's `/meta` stays identifier-keyed, so it shows no community content for such a
book yet.
[Bulk community matching](../server/api/reference.md#bulk-community-matching).

From 2.7.0 the server's `/meta` answers such a book from its recorded match. It
tries the book's identifiers first, then its `book_matches` `work_id` and
`recording_id` when they find nothing. The envelope is the same, so the wire does
not change.

The player's book page asks `/meta` for every loaded book that is not an episode
(`bookPageMetaEnabled`), so a book matched with neither identifier shows its
community content there. Most other surfaces keep the identifier gate
(`metaEnabledFor`). [Enriched book metadata](../frontend/state-and-data.md#enriched-book-metadata)
lists which surface uses which gate.

### Community chapters from the meta side

Expand Down Expand Up @@ -934,6 +945,10 @@ These are all additive:
writes the legal text.** A client must render it beside that content and never
compose it.
- `recording.chapter_count`.
- `recording.librivox` `{id, url?, zip_url?}`, only on a LibriVox recording (see
[`GET /libraries/{id}/meta`](../server/api/reference.md#get-apiv1librariesidmeta)).
The server checks each URL, and the player checks `url` again before About
links it.
- `local` `{library_id, path}` on each rail entry the caller owns, in the main view
and the orderings. The server resolves `local` **per request, after the shared
cache, on a copy**, and never stores it there. A failure placing the caller's books
Expand Down
3 changes: 2 additions & 1 deletion docs-developers/bridge/attachments.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,8 @@ book's signature names a characters, recaps or description file.
chapters, and a map is how its text lines up; neither is generated content.
- It is read from the narrow column, so a list never joins `book_attachments`.
- Every book object carries it: lists, search, `item`, recent, and the next book's
`book`. The player uses it to ask `/meta` for a book with no ASIN or ISBN.
`book`. The player's identifier-gated surfaces (`metaEnabledFor`) use it to ask
`/meta` for a book with no ASIN or ISBN.

Off the wire, `Book.GeneratedParts` says which parts the signature names. So
`/meta` and the console's book page read only the columns a book has.
Expand Down
11 changes: 11 additions & 0 deletions docs-developers/frontend/book-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,17 @@ hero and the tabs.
(`openLibraryRoot`) on `libraryBooksHref({ genre | tag })`.
- A matched book without community text keeps "View on AudioSilo Meta". Long text
folds past six lines with Show more.
- A LibriVox recording adds "Also free on LibriVox" (`book.about.librivox`) under
the other links out. It comes after the credit and **Improve this**, else
after "View on AudioSilo Meta". It opens `librivoxUrl(recording)` through
`openExternalUrl`.
`librivoxUrl` (`book-details-model.ts`) reads the matched recording's
`librivox.url` and keeps it only when it is an absolute http(s) URL with a host
(`urlEndpoint`). It lower-cases the scheme, because the in-app browser on iOS
(`expo-web-browser`) refuses `HTTPS://` and `openExternalUrl` swallows the
error. The server checks the URL too. The player checks again because the
link leaves the app. With no `librivox.url`, or on an older server, there is
no link.
- **Other versions** - `BookVersions`: every other copy (`useBookCopies`) with where it
lives and its quality, each opening that copy's page. Nothing shows when there is
none.
Expand Down
8 changes: 5 additions & 3 deletions docs-developers/frontend/offline.md
Original file line number Diff line number Diff line change
Expand Up @@ -989,9 +989,11 @@ a marker (`DownloadManifest.meta = { savedAt }`).
write the registry once (`persistSoon`). If the download was removed or replaced
while the file was written, the marker isn't set, and a removed book's file is
deleted again.
- *What `captureOfflineMeta` skips.* A book that can't match because it has no ASIN
or ISBN (`canMatch`, which reads the cached full item first, since the manifest's
book can be the list shape). Also a server without `metadata`.
- *What `captureOfflineMeta` skips.* A book with no ASIN or ISBN and no generated
content (`canMatch`, `metaEnabledFor` on the cached full item first, since the
manifest's book can be the list shape). That includes a book matched only in the
console. Online its page shows the match. Offline it reads as unmatched. Also a
server without `metadata`.
- *How it reads.* It reads through the screens' own option factories
(`bookMetaQuery`, `metaWorkQuery`), so it reuses a fresh answer they hold. It never
throws and never retries.
Expand Down
5 changes: 3 additions & 2 deletions docs-developers/frontend/podcasts.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,9 @@ audio changed and the place is kept. Nothing is rescaled.
There is no Generate, community metadata, rating or series. The player's
companion chips and tabs are off for an episode (`useCompanionData` reads
`book.episode` as `off`). The player never asks for community metadata for an
episode: `metaEnabledFor` in `meta-gating.ts` is false for `book.episode`, because
a feed's tags can carry an ISBN-like value.
episode: `bookPageMetaEnabled` in `meta-gating.ts`, which `metaEnabledFor` builds
on, is false for `book.episode`, because a feed's tags can carry an ISBN-like
value.

### Wording in the player and on Home

Expand Down
25 changes: 21 additions & 4 deletions docs-developers/frontend/state-and-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,9 @@ older server (React Query rejects it instead), and the query stays pending.
it; the series page and Search's character group do. `community_description`
feeds the book page's About card (`aboutContent`).
- `BookMetaRecording.chapter_count`, which has no reader yet.
- `BookMetaRecording.librivox` (`BookMetaLibrivox { id, url?, zip_url? }`), only
on a LibriVox recording. The About card links `url`
([The aside](book-page.md#the-aside)); nothing reads `id` or `zip_url` yet.
- `local` (`BookRef`) on rail entries. It is how the series page and Next in
your series tell an owned book from a ghost.
- `previous` on a matched `BookMeta`. The
Expand Down Expand Up @@ -983,10 +986,24 @@ Each field is read through one of two readers, never both:
[generated work](../server/api/reference.md#generated-content) (`matched: false`
with a `work`). So an unmatched book's generated description reaches About too.

`metaEnabledFor` asks `/meta` for a book with an ASIN or ISBN **or**
`generated: true`. An answer that is only generated content goes stale after
2 minutes (`META_GENERATED_ONLY_STALE_MS`), rather than the hour a community
answer keeps.
Two gates in `meta-gating.ts` decide whether a screen asks `/meta`:

- `bookPageMetaEnabled`: the `metadata` capability and a loaded book that is not
an episode, identifiers or not. Only the book page uses it. A newer server
answers a book with no ASIN or ISBN from its console match (a LibriVox
download), and an older one answers `matched: false` without asking upstream,
so the one request is cheap either way.
- `metaEnabledFor`: that gate and an ASIN, an ISBN **or** `generated: true`. The
companion and reveal toast, Home's Now card and Previously on, the end credits
and the offline copy (`canMatch` in `offline-meta.ts`) use it, because they ask
for a book in passing or for every download.

The series page's anchor (`metadataAnchor`, an ASIN or ISBN) and Search's
characters (`characterBooksToLoad`, the started books, identifiers or not) pick
their books by their own rules.

An answer that is only generated content goes stale after 2 minutes
(`META_GENERATED_ONLY_STALE_MS`), rather than the hour a community answer keeps.

`work.generated` drives the "Generated on this server" chip (`GeneratedChip`).
`GeneratedMark` heads a Characters panel and the Previously On card's recap, and
Expand Down
71 changes: 55 additions & 16 deletions docs-developers/server/api/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -2322,8 +2322,9 @@ its other series (`series_list`) in their order. The community rails answer firs
but only when they can **place** the next entry on one of the caller's books.
Otherwise the local steps decide, in order, and the first one with an answer wins:

1. **`community`**: community metadata is on and the book matches a work with at
least one series rail.
1. **`community`**: community metadata is on and the book's ASIN or ISBN matches
a work with at least one series rail. Unlike `/meta`, this step never reads
the book's recorded match.
- **Rail order.** The rails are taken in the book's own order. First comes a
rail named like its main series (by the rail's name or one of its orderings',
ignoring case, accents, punctuation and spacing). Then come rails named like
Expand Down Expand Up @@ -2421,13 +2422,27 @@ path is authorized against the caller's share scope exactly like `/item`. The ro
is gated by the `metadata` [capability](#get-apiv1server). When it is false the
route returns 404, so a client that honours the flag never calls this.

The server resolves the book's `asin` or `isbn`. These are the effective fields on
the indexed book: stored in `book_enrichment`, or set by an admin edit, which wins.
It looks the recording up upstream and folds the matched recording plus up to three
The server finds the book's work in two steps (`bookEnvelope`):

1. **Its identifiers.** The server looks the book's `asin` or `isbn` up upstream.
These are the effective fields on the indexed book: stored in
`book_enrichment`, or set by an admin edit, which wins. A book its identifiers
match never reads its recorded match.
2. **Its recorded match.** When the book has neither identifier, or the upstream
does not know them, the server reads the book's recorded community match. That
is its `book_matches` row, shown as
[`community_match`](#get-apiv1adminlibrariesidbook) on the console's book page:
the `work_id` and `recording_id` an admin accepted or a match run applied. The
server fetches that work (`works/{id}`, `meta.Service.EnrichMatch`) and uses
the match's recording as the envelope's recording, as a lookup would. When
the match names no recording, or one the work no longer has, that is the
work's first. A book with no match row is unmatched.

Either way the server folds the matched recording plus up to three
series rails into one envelope, one rail per reading-order family (see
[Reading-order families](#reading-order-families)). Every series-rail entry carries its own `web_url`, so a client links
to the metadata site without ever building a URL itself. Answers are cached in
memory and in the server's database (see
memory and in the server's database, keyed by identifier or by the recorded match (see
[Configuration](../configuration.md#community-metadata-metadata)), so a restart
serves them warm and a known book keeps its enrichment through a metadata-service
outage.
Expand Down Expand Up @@ -2523,6 +2538,27 @@ the upstream has none. Every `omitempty` string or number field (`subtitle`,

- `recording.chapter_count` is the matched recording's chapter count, omitted when
the metadata service does not know it.
- `recording.librivox` (2.7.0, additive) says where a LibriVox recording is
published. It is present only when the community gives the recording a LibriVox
project id (`librivox_id` above 0), and absent on every other recording and on
older servers:

```json
"librivox": {
"id": 47,
"url": "https://librivox.org/…",
"zip_url": "https://archive.org/compress/…"
}
```

`id` is the LibriVox project id. `url` is the recording's page on librivox.org
and `zip_url` the whole recording as one zip, from the community's
`links.librivox` and `links.zip` (see
[The `links` field](../../meta/data-model.md#the-links-field)). Each is
optional, and the server keeps it only
when it is an absolute `http` or `https` URL with a host (`librivoxOf`). It
never builds either from `id`, because LibriVox's pages are named by slug. A
cached envelope gains the field once its entry is next refreshed.
- `series[].position` is this work's position in that series. Positions are
strings ("1", "2.5", "1-3.5").
- `series[].works` is the full ordered rail, **including the current work**. The
Expand Down Expand Up @@ -2822,8 +2858,8 @@ this param: it gates on the device against its live position, which can be ahead
of the last saved one. `spoilers=hide` is for clients that cannot gate
themselves.

When the book has neither an `asin` nor an `isbn`, or the upstream reports no
match, the response is `200 { "matched": false }`. That is a normal, non-error result
When neither the book's identifiers nor its recorded match (if any) find a work
upstream, the response is `200 { "matched": false }`. That is a normal, non-error result
that the client treats as "nothing to show":

```json
Expand Down Expand Up @@ -2869,10 +2905,11 @@ content wins **per dimension**:
`sidecars.describe` job shows its own time.
- The whole object is omitted when nothing was generated, or the community has
it all.
- **A book with no match** (no `asin`/`isbn`, or none upstream) but generated
content answers `matched: false` **with a work** of its own: `id` empty,
`title` the book's, `authors` `[]`, the generated `characters`, `recaps`,
`recap_summary` and `generated_description`, and `generated`:
- **A book with no match** (neither its identifiers nor its recorded match find
a work) but generated content answers `matched: false` **with a work** of its
own: `id` empty, `title` the book's, `authors` `[]`, the generated
`characters`, `recaps`, `recap_summary` and `generated_description`, and
`generated`:

```json
{ "matched": false, "work": { "id": "", "title": "The Book", "authors": [], "language": "",
Expand Down Expand Up @@ -2937,7 +2974,7 @@ generated description, because it is a field they don't know.
| `400` | missing `path` / invalid library id |
| `403` | path outside the caller's share scope |
| `404` | `no book at that path`, **or** metadata lookup is disabled on this server (`metadata` capability false) |
| `500` | with `spoilers=hide`, `could not load progress` - the caller's progress could not be read; the ungated envelope is never sent instead |
| `500` | `could not load book` - the book or its recorded match could not be read. With `spoilers=hide`, also `could not load progress` - the caller's progress could not be read; the ungated envelope is never sent instead |
| `502` | `metadata service unavailable` - the upstream was unreachable or errored, the server holds no earlier answer for the book, and it has no generated content |

### `GET /api/v1/libraries/{id}/meta/cover`
Expand Down Expand Up @@ -2996,7 +3033,8 @@ reach the client.
| `400` | `url is required`; `size must be one of [96 160 320 640]` (an empty `size=` too); `path is required` / `invalid library id` |
| `401` | missing/invalid token |
| `403` | path outside the caller's share scope |
| `404` | `no such cover` - the book is unmatched (no `asin`/`isbn`, or no match upstream) or its envelope hands out no such URL; `no cover` - the image isn't one the server can decode, or is over 40 megapixels; `no book at that path`; `metadata lookup not enabled` (`meta_covers` false) |
| `404` | `no such cover` - the book is unmatched (neither its identifiers nor its recorded match find a work) or its envelope hands out no such URL; `no cover` - the image isn't one the server can decode, or is over 40 megapixels; `no book at that path`; `metadata lookup not enabled` (`meta_covers` false) |
| `500` | `could not load book` - the book or its recorded match could not be read |
| `502` | `metadata service unavailable` - the book's envelope couldn't be had; `code: "cover_unavailable"` - the image couldn't be fetched (an address that isn't public, a timeout, too many redirects, an answer other than `200`), or its fetch failed less than a minute ago |

### `GET /api/v1/meta/work`
Expand Down Expand Up @@ -6421,8 +6459,9 @@ is a `400` too (`too many format or codec values`).
duplicates groups' copies and the book page's `book` carry it too.
- `matched` - the book has an ASIN or ISBN, or a recorded community match (a store
listing with neither, from 2.6.0): the same rule as the `matched` filter
(`catalog.matchedExpr`). The player's `/meta` and the community chapter check find
a book by its ASIN or ISBN alone, so check `asin` / `isbn` for those, not `matched`.
(`catalog.matchedExpr`). The community chapter check finds a book by its ASIN or
ISBN alone, so check `asin` / `isbn` for it, not `matched`. `/meta` also reads
the recorded match, so it can answer a `matched` book with neither identifier.
- `abridged` - `true`, `false`, or `null` while nothing says (always present,
unlike the player's `Book`, which omits it). `genres` and `tags` are `[]` for
none.
Expand Down
21 changes: 12 additions & 9 deletions docs-developers/server/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,8 @@ saving (see [`GET /admin/settings`](api/reference.md#get-apiv1adminsettings)).
### Community metadata (`metadata.*`)

The server can enrich a book with a description, production details and its
series. It resolves the book's ASIN or ISBN against the community metadata API
series. It resolves the book's ASIN or ISBN, else the book's recorded community
match, against the community metadata API
([meta.audiosilo.app](https://meta.audiosilo.app)) and serves the result at
`GET /libraries/{id}/meta`. Players draw it as an "About this book" block plus
the recaps, characters and series tabs. The same config also gates
Expand Down Expand Up @@ -681,17 +682,19 @@ database, in the `meta_cache` table (migration `0024`; see
makes the cache survive a restart and an outage:

- **What it holds:**
- A book's enrichment, keyed by its ASIN or ISBN: a match, a "no match", or an
envelope missing a series rail. An envelope missing a rail is kept for 2
minutes only, and never over a stored answer, which stays the outage fallback.
- A book's enrichment, keyed by its ASIN or ISBN, or by its recorded community
match's work and recording ids for a book its identifiers don't match: a
match, a "no match", or an envelope missing a series rail. An envelope
missing a rail is kept for 2 minutes only, and never over a stored answer,
which stays the outage fallback.
- Works fetched by id (`/meta/work`, and the previous books `/meta` adds),
matches only.

It never holds a service error, and never a row for an unknown work id. An id
is the caller's choice, so storing misses would let any user grow the table. A
`404` from the service for a work already stored replaces its row, so a dropped
work isn't served again in a later outage. In mirror mode a `404` the local copy
gave never does (see [Mirror mode](#mirror-mode-metadatamode-mirror)).
It never holds a service error, and never a row for a work id that `/meta/work`
or `previous` found unknown. That id is the caller's choice, so storing misses
would let any user grow the table. A `404` from the service for a work already
stored replaces its row, so a dropped work isn't served again in a later
outage. In mirror mode a `404` the local copy gave never does (see [Mirror mode](#mirror-mode-metadatamode-mirror)).
- **How it is read:** after a memory miss. A row still within its TTL is served
and warms memory for the rest of that TTL. A match past its TTL is not served
while the service answers, but when the service fails it is served anyway
Expand Down
Loading
Loading