diff --git a/docs-developers/frontend/overview.md b/docs-developers/frontend/overview.md index b0dab9f..144ddf4 100644 --- a/docs-developers/frontend/overview.md +++ b/docs-developers/frontend/overview.md @@ -81,11 +81,11 @@ handful of standalone screens. | `/search` | `(app)/search.tsx` | Search across all connections, de-duplicated; shares its query text with the desktop top bar via `useSearchStore`. | | `/library` | `(app)/library/index.tsx` | All libraries from all connections, plus a Favourites shelf row. | | `/library/favourites` | `(app)/library/favourites.tsx` | The favourites list (un-heart in place). | -| `/library/[libraryId]` | `(app)/library/[libraryId]/index.tsx` | Library root browse - a two-line re-export of `src/components/library/browse-screen.tsx`. | -| `/library/[libraryId]/[...path]` | `(app)/library/[libraryId]/[...path].tsx` | Nested folder browse - same `BrowseScreen`, the catch-all segments become the folder `path` (helpers in `src/lib/paths.ts`). | -| `/book/[libraryId]/[...path]` | `(app)/book/[libraryId]/[...path].tsx` | Book detail: play/resume, download control, chapters, bookmarks, notes, listening history, other versions of the same book. | +| `/library/[libraryId]?connection=…&path=…` | `(app)/library/[libraryId].tsx` | Library browse, root and nested folders alike - a two-line re-export of `src/components/library/browse-screen.tsx`. Content routes are **flat**: the connection id and the library-relative folder `path` ride as query params, never as nested route segments (an in-app `router.push` cannot resolve a route nested under a dynamic layout segment - it lands on the group's first child; rationale and helpers in `src/lib/paths.ts`). The `(app)` layout republishes `?connection=` as the scope the content hooks read via `useScopedCid()`. | +| `/book/[libraryId]?connection=…&path=…` | `(app)/book/[libraryId].tsx` | Book detail: play/resume, download control, chapters, bookmarks, notes, listening history, other versions of the same book, and a capability-gated community-metadata section ([State & data](state-and-data.md#enriched-book-metadata)). Same flat query-param addressing as the library routes. | | `/downloads` | `(app)/downloads.tsx` | Downloaded books + storage used ([Offline](offline.md)). | -| `/settings` | `(app)/settings.tsx` | Playback tunables, language, theme, connections, self-service password (the sign-out guard nudges a password-less user to set one via `sign-out-confirm.tsx`), per-server API keys (capability-gated), sign-out. | +| `/settings` | `(app)/settings.tsx` | App-level preferences only: playback tunables, language, theme, plus the Servers list that opens each connection's account screen. | +| `/account?connection=…` | `(app)/account.tsx` | Per-connection account screen, reached from the Settings screen's Servers list: set/change the self-service password (the sign-out guard nudges a password-less user here via `sign-out-confirm.tsx`), personal API keys (capability-gated, demo-hidden), and sign-out. | | `/player` | `src/app/player.tsx` | The full player, presented as a full-screen modal above the shell. Accepts `libraryId`/`path` (+ optional `position`/`track`) params and gates playback start on the chapters query settling. | | `/finished` | `src/app/finished.tsx` | The end-credits screen shown when a book finishes (or from the player's menu). A root modal sibling of the player; renders `EndCredits` with an "up next" suggestion. See [Playback](playback.md#ending-a-book-end-credits-and-up-next). | | `/connect` layout | `src/app/connect/_layout.tsx` | Onboarding stack. An **authenticated** user is bounced home unless they are adding another server (`?add=1`, a pairing `?token=`, or a sign-in mid-flow via `pendingServerUrl`) - the app supports multiple simultaneous server connections. | diff --git a/docs-developers/frontend/state-and-data.md b/docs-developers/frontend/state-and-data.md index 50b568e..a0c4b7a 100644 --- a/docs-developers/frontend/state-and-data.md +++ b/docs-developers/frontend/state-and-data.md @@ -70,8 +70,8 @@ servers at once, and two of them can each have a "library 1"): `qk.item(cid, lib path)`, `qk.chapters(cid, lib, path)`, `qk.progress(cid, lib, path)`, `qk.allProgress(cid)`, `qk.bookmarks/notes/history(cid, lib, path)`, `qk.favourites(cid)`, `qk.libraries(cid)`, `qk.browse(cid, lib, path)`, -`qk.server(cid)` - so mutations can invalidate precisely and one server's cache -never shadows another's. Content keys are `(connectionId, libraryId, path)` tuples, +`qk.bookMeta(cid, lib, path)`, `qk.server(cid)` - so mutations can invalidate +precisely and one server's cache never shadows another's. Content keys are `(connectionId, libraryId, path)` tuples, extending the path-is-identity rule across connections. Patterns to copy when adding an endpoint: @@ -105,6 +105,43 @@ where a result lives ("server · library") for de-duplicated rows. queries when the server becomes reachable again - screens that errored or emptied while offline repopulate without a remount. +### Enriched book metadata + +The book screen (`src/app/(app)/book/[libraryId].tsx`) draws a +`BookMetaSection` (`src/components/library/book-meta.tsx`) beneath the +file/chapter list, in both the wide and phone branches, following the +section-per-concern pattern (the same shape as the bookmarks/notes sections). It +shows a description (collapsed past ~300 characters with a show-more toggle), +compact production details (publisher, release date, first published, an +"abridged" badge), a horizontal **more in this series** rail, and a quiet **View +on AudioSilo Meta** link. + +Three details make it safe to add to a screen everyone sees: + +- **Capability-gated.** The section mounts only when the server advertises the + `metadata` capability (`!!server.capabilities.metadata`, optional in + `types.ts` so an older server reads as absent → false). On a server without + the feature the query never fires. +- **Progressive enhancement.** The component renders **nothing** while loading, + on error, or on `{ matched: false }` - the page is never worse for having the + section, and its absence is indistinguishable from a book the metadata service + doesn't know. +- **Fetch tuned for a best-effort side dish.** `useBookMeta` keys on + `qk.bookMeta(cid, lib, path)` with a **1 h `staleTime`** (the server caches the + composed envelope too, so re-fetching sooner buys nothing) and **`retry: false`** + so a 502 from a down metadata service resolves once and stops - it must not + spin or block the rest of the screen. + +`client.bookMeta(libraryId, path, signal)` calls `GET /libraries/{id}/meta`; the +`BookMeta` discriminated union (`{ matched: false } | { matched: true; work; +recording?; series?; web_url }`) in `types.ts` is hand-mirrored from the server's +envelope (the mirroring rule above applies - a change to the shape is a two-repo +change with tests on both sides). Every series-rail entry +carries its own `web_url`, so tapping a series work or the footer link opens the +metadata site **externally** (a real new tab on web, an in-app browser tab on +native) - the client never constructs a metadata URL. UI strings live under +`book.meta.*` in the locale catalogs. + ## Zustand stores ### Session (`src/stores/session.ts`) diff --git a/docs-developers/server/api/index.md b/docs-developers/server/api/index.md index 5a18802..98dc1fd 100644 --- a/docs-developers/server/api/index.md +++ b/docs-developers/server/api/index.md @@ -197,7 +197,8 @@ a `limit` and no pagination. "transcode": true, "upload": false, "websocket": false, - "api_keys": true + "api_keys": true, + "metadata": true }, "auth": { "methods": ["auth_code", "password"] }, "demo": { "enabled": false } @@ -207,7 +208,9 @@ a `limit` and no pagination. Clients **must** feature-gate on these flags rather than probing endpoints: `transcode` reflects whether ffmpeg is configured (without it, `?transcode=1` is 503), `web_player` whether `/web` is mounted, `api_keys` whether the server -supports user-minted [API keys](reference.md#personal-api-keys), +supports user-minted [API keys](reference.md#personal-api-keys), `metadata` +whether [community metadata lookup](reference.md#get-apiv1librariesidmeta) is +configured (the enriched-book section is drawn only when it is true), `upload`/`websocket` are roadmap phases that will flip on when they land. `demo.enabled` drives the "Try the demo" affordance. diff --git a/docs-developers/server/api/reference.md b/docs-developers/server/api/reference.md index b81dd68..83732ee 100644 --- a/docs-developers/server/api/reference.md +++ b/docs-developers/server/api/reference.md @@ -36,7 +36,8 @@ else and gate features on the flags. "transcode": true, "upload": false, "websocket": false, - "api_keys": true + "api_keys": true, + "metadata": true }, "auth": { "methods": ["auth_code", "password"] }, "demo": { "enabled": false } @@ -52,7 +53,10 @@ it the moment it pairs. `version` is stamped from the release tag (`"dev"` for l `transcode` is true only when ffmpeg is configured; `web_player` only when the `/web` mount is populated; `upload` and `websocket` are reserved for future phases and currently always false. `api_keys` is true on servers that support -user-minted [personal API keys](#personal-api-keys). +user-minted [personal API keys](#personal-api-keys). `metadata` is true when the +server has [community metadata lookup](#get-apiv1librariesidmeta) configured +(`metadata.enabled` and a `metadata.base_url`); clients gate the enriched-book +section on it. ### `GET /healthz` · `GET /api/v1/healthz` @@ -662,6 +666,93 @@ multi-file mp3 parts render identically. Same status codes as `/item`. +### `GET /api/v1/libraries/{id}/meta` + +*Session.* Community metadata enrichment for a book - a description, production +details, and the series it belongs to - composed server-side from the community +metadata API ([meta.audiosilo.app](https://meta.audiosilo.app)) and cached. The +path is authorized against the caller's share scope exactly like `/item`. 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`/`isbn` (the fields on the indexed book, +backfilled by the manager into `book_enrichment`), looks the recording up +upstream, and folds the matched recording plus up to three series rails into one +envelope. Every series-rail entry carries its own `web_url`, so a client links to +the metadata site without ever building a URL itself. + +| Query param | Type | Required | +|---|---|---| +| `path` | string | yes | + +Response `200` on a match: + +```json +{ + "matched": true, + "work": { + "id": "the-martian", + "title": "The Martian", + "subtitle": "", + "authors": [{ "id": "andy-weir", "name": "Andy Weir" }], + "language": "en", + "first_published": "2011", + "description": "An astronaut is stranded on Mars…" + }, + "recording": { + "id": "podium-2013", + "narrators": [{ "id": "r-c-bray", "name": "R. C. Bray" }], + "abridged": false, + "runtime_min": 634, + "release_date": "2013-03-22", + "publisher": "Podium Audio", + "cover_url": "https://…" + }, + "series": [ + { + "id": "wandering-earth", + "name": "The Wandering Earth", + "position": "1", + "works": [ + { + "id": "the-martian", + "title": "The Martian", + "position": "1", + "authors": [{ "id": "andy-weir", "name": "Andy Weir" }], + "cover_url": "https://…", + "web_url": "https://meta.audiosilo.app/work?id=the-martian" + } + ] + } + ], + "web_url": "https://meta.audiosilo.app/work?id=the-martian" +} +``` + +`work` is always present on a match; `recording` and `series` are omitted when +the upstream has none, and every `omitempty` string/number field (`subtitle`, +`first_published`, `description`, `abridged`, `runtime_min`, `release_date`, +`publisher`, `cover_url`) is dropped when empty. `series[].position` is this +work's position in that series; `series[].works` is the full ordered rail, +**including the current work** (the client filters it out before drawing a "more +in this series" row). Positions are strings ("1", "2.5", "1-3.5"). + +When the book has neither an `asin` nor an `isbn`, or the upstream reports no +match, the response is `200 { "matched": false }` - a normal, non-error result +that the client treats as "nothing to show": + +```json +{ "matched": false } +``` + +| Status | Meaning | +|---|---| +| `200` | `{ "matched": true, … }` on a match, or `{ "matched": false }` when there is nothing to show | +| `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) | +| `502` | `metadata service unavailable` - the upstream was unreachable or errored | + ## Streaming & media Both routes take *media auth* (header **or** `?token=`) and are exempt from the diff --git a/docs-developers/server/configuration.md b/docs-developers/server/configuration.md index 98855ba..5764629 100644 --- a/docs-developers/server/configuration.md +++ b/docs-developers/server/configuration.md @@ -108,6 +108,26 @@ case they live only in the database. There is deliberately **no layout key** | `demo.max_users` | int (optional), unset | Cap on concurrent live demo accounts. **Unset → safe default 200** (`config.DefaultDemoMaxUsers`); **explicit `0` = unlimited** (opt-in risk: per-IP creation limits are bypassable by rotating IPs) | | `demo.idle_ttl` | duration string, `"24h"` | Reap demo accounts idle longer than this (background reaper sweeps every 15 min). Must parse as a positive `time.Duration`; empty falls back to 24h | +### Community metadata (`metadata.*`) + +The server can enrich a book with a description, production details and its +series by resolving the book's ASIN/ISBN against the community metadata API +([meta.audiosilo.app](https://meta.audiosilo.app)) and serving the result at +`GET /libraries/{id}/meta` (players draw it beneath the chapter list). The +lookup is server-side by design - one cached seam, and one config key that turns +off all outbound calls. + +| Key | Type / default | Meaning | +|---|---|---| +| `metadata.enabled` | bool, `true` | Turn the metadata lookup on. When `false`, the server makes **no outbound metadata calls**, `GET /libraries/{id}/meta` returns 404, and the `metadata` capability reports false so players hide the enriched-book section entirely | +| `metadata.base_url` | string, `"https://meta.audiosilo.app"` | Base URL of the metadata service (the site is served at `/` and the API at `/api/v1`). **Must be an absolute `http`/`https` URL when metadata is enabled** | + +Turning it off is the one-key privacy switch: with `metadata.enabled: false` +the server never contacts the metadata service, and every player connected to it +stops showing the section (they gate on the `metadata` capability). Enrichment is +strictly additive and cached - a slow or unreachable service degrades to no +section, never a broken page. + ## Environment variables `applyEnv` overrides a fixed set of keys from `AUDIOSILO_*` variables - this @@ -128,6 +148,8 @@ is the complete list (anything not here, e.g. `app_links`, `libraries`, | `AUDIOSILO_DEMO_LIBRARY` | `demo.library` | string | | `AUDIOSILO_DEMO_MAX_USERS` | `demo.max_users` | integer (`0` = unlimited) | | `AUDIOSILO_DEMO_IDLE_TTL` | `demo.idle_ttl` | Go duration, e.g. `24h` | +| `AUDIOSILO_METADATA_ENABLED` | `metadata.enabled` | `strconv.ParseBool` (`true`/`1`/…) | +| `AUDIOSILO_METADATA_BASE_URL` | `metadata.base_url` | URL | List values are split on commas with whitespace trimmed and empties dropped (`splitList`). Numeric/boolean variables that fail to parse are **silently @@ -144,7 +166,8 @@ overrides). It rejects: - any `trusted_proxies` entry that isn't a valid CIDR; - a library with an empty name or root, or a duplicate library name; - demo mode without `demo.library`; a `demo.idle_ttl` that doesn't parse or - isn't positive (rejected loudly rather than silently replaced by 24h). + isn't positive (rejected loudly rather than silently replaced by 24h); +- metadata enabled with an empty or non-absolute-`http(s)` `metadata.base_url`. Secure-by-default choices baked into `Default()` and first-run: TLS on (`selfsigned`) out of the box, no default passwords (credentials are minted diff --git a/docs-users/listening/browsing.md b/docs-users/listening/browsing.md index b120ca4..97ea1d8 100644 --- a/docs-users/listening/browsing.md +++ b/docs-users/listening/browsing.md @@ -51,6 +51,16 @@ Tap a book to open its detail page: If the same book exists in more than one place (say, an M4B copy and an MP3 copy), the detail page offers **Choose a version** so you can pick which copy to play. +### About this book + +Some books show an extra section below the chapters with more about the title: + +- An **About** description of the story, and production details such as the **publisher**, **release date** and when the work was **first published**. +- A **More in this series** row - other books in the same series, each opening its page on the metadata site so you can see what to read next. +- A **View on AudioSilo Meta** link to the book's full entry. + +This information comes from the **AudioSilo community metadata database** at [meta.audiosilo.app](https://meta.audiosilo.app), a free, community-run catalogue of audiobook details. It appears only when the book can be matched (it carries an ASIN or ISBN) and your server has the metadata lookup switched on. If a book has no extra section, it simply isn't matched or your admin has turned the feature off - nothing else on the page changes. A server admin can disable the lookup entirely, in which case no book shows this section (see the metadata setting in the [configuration reference](/developers/server/configuration)). + ## Search The **Search** tab (or the search box in the desktop header) searches **titles, authors, and series** across every library you have access to - and across every server you've added: