diff --git a/docs-developers/architecture/cross-repo-contract.md b/docs-developers/architecture/cross-repo-contract.md index 23b937e0..ed9f785b 100644 --- a/docs-developers/architecture/cross-repo-contract.md +++ b/docs-developers/architecture/cross-repo-contract.md @@ -873,6 +873,12 @@ 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. +A book [added from LibriVox](#librivox-fetches) gets its row from the server's +fold of the done `librivox.fetch`, with the work and recording the server resolved +at queue time (`how` `accepted`). Where a LibriVox recording is on the server is +read from these rows alone, by their exact, non-empty `recording_id`, never from +`/meta`'s first-recording answer for a work. + 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 @@ -1373,6 +1379,7 @@ server's reader. See [The .audiosilo folder](../bridge/attachments.md). | `audible.poll` | Polls one Audible account. | | `audible.import` | Backs up one Audible title. | | `audible.sync` | Syncs listening positions with one Audible account. | +| `librivox.fetch` | Downloads one LibriVox recording into a books library. | Each kind's rules follow. @@ -1594,6 +1601,34 @@ feed also answers `400 invalid_request`. See [Position sync](../bridge/audible.md#position-sync-audiblesync-and-the-push-loop). +### LibriVox fetches + +`librivox.fetch` (feature `librivox`) is internal and writing, for a books library, +not quiet, deduped per library and project, and in no module. + +- Three admin routes queue and support it: `GET /admin/librivox/search`, + `GET /admin/librivox/work` and `POST /admin/librivox/fetch`. All are `404 + metadata_off` while community metadata is off. There is no `/server` flag: a + client gates on the `librivox` feature. +- The server builds its params, `{librivox_id, work_id, recording_id, library_id}`, + with the work and recording from metaserve's `lookup?librivox_id=` at queue + time. The Bridge reads them leniently and ignores unknown fields. +- The Bridge reads the manifest from its own metadata service and downloads only + from `https` archive.org hosts, checking every redirect. +- It reports progress steps `manifest|download|cover|place|rescan`. The result is + `{library_id, path, librivox_id, files, bytes}`, where `path` is the book path + that `POST /bridge/rescan` answered. +- A failure is `{code, message}`, with `not_writable`, `exists`, `disk_full`, + `not_found`, `archive_unavailable` or `metadata_off`. +- It uses no new Bridge route. +- The Bridge writes `.audiosilo/librivox.json` + (`{version, librivox_id, work_id, recording_id, archive_id}`) into the book's + folder. The server does not read it yet, and its scanner skips the name. +- The server folds a done result into the book's `book_matches` row + ([Matches by work id](#matches-by-work-id)). + +See [Add from LibriVox](../bridge/librivox.md). + ### The player's Bridge surface The player's first admin-only UI is gated on all of these: @@ -1609,6 +1644,10 @@ Its confirmation holds Generate back while the estimate's `open_job` is a for a book with no ASIN or ISBN. Its `types.ts` mirrors the job, the estimate, `BookMetaWork.generated` and `BookMetaWork.generated_description` by hand. +Find on LibriVox is gated the same way with `librivox` in place of `sidecars`, so +it shows only while the Bridge is online. Its `types.ts` and `client.ts` mirror the +three LibriVox routes by hand. + ### Bridge seam and old clients Everything is additive. @@ -1643,6 +1682,15 @@ Audible backups: it (`kind_unavailable`, and a poll's policy pass queues nothing). - Its account PUT's `policy` still creates an account as before. +LibriVox fetches: + +- An older server doesn't know `librivox.fetch`, so its `features` never list + `librivox`, and neither the console nor the player offers the feature. +- An older Bridge never advertises it, so the fetch route answers + `kind_unavailable`. +- A shipped player never sees Find on LibriVox. A book added this way is an + ordinary book with a recorded match. + Audible position sync: - An older Bridge never advertises `audible.sync`, so the server queues none and the diff --git a/docs-developers/bridge/attachments.md b/docs-developers/bridge/attachments.md index 1b6a7381..86efe93b 100644 --- a/docs-developers/bridge/attachments.md +++ b/docs-developers/bridge/attachments.md @@ -24,6 +24,10 @@ The prefix keeps the books of one folder apart, since a collection folder's book share one `.audiosilo/`. These are the book attachment names; podcast-specific files are described below. Audio is never stored in `.audiosilo/`. +A book [added from LibriVox](librivox.md#audiosilolibrivoxjson) also holds +`.audiosilo/librivox.json`, which names its LibriVox recording. It is no +attachment. The server skips the name and serves nothing from it. + ### Podcast CHAP attachments `podcast.fetch` writes `/.audiosilo/.chapters.json`, stripping only diff --git a/docs-developers/bridge/configuration.md b/docs-developers/bridge/configuration.md index e7713298..5872484e 100644 --- a/docs-developers/bridge/configuration.md +++ b/docs-developers/bridge/configuration.md @@ -18,7 +18,9 @@ the images (`AUDIOSILO_BRIDGE_DATA`), else `--data`, else `~/.audiosilo-bridge`. the defaults and the environment apply; - the SQLite database `bridge.db`; - the sealed `secrets/` and their key `secret.key`; -- the tools the Bridge downloads. +- the tools the Bridge downloads; +- `librivox/`, one state file per LibriVox fetch + ([Staging, resume and cleanup](librivox.md#staging-resume-and-cleanup)). Settings are read from `/config.yaml`, then overridden by `AUDIOSILO_BRIDGE_*` variables. An **empty** variable counts as unset. A variable that doesn't parse @@ -227,6 +229,17 @@ and tests. Release builds and images never carry the tag, so there they do nothi | `AUDIOSILO_BRIDGE_AUDIBLE_FAKE_URL` | the base URL of an Audible fake (`go run -tags audiblefake ./cmd/audiblefake`): every Audible and Amazon endpoint goes there instead | | `AUDIOSILO_BRIDGE_AUDIBLE_RECORD_DIR` | records every Audible exchange into a `0600` JSON-lines file in this folder, scrubbed of credentials as it is written. It is for recording fixtures from a real account | +## LibriVox + +`librivox.fetch` has no settings of its own. It reads LibriVox manifests from the +engine's `metadata.base_url` (`AUDIOSILO_BRIDGE_METADATA_BASE_URL`, default +`https://meta.audiosilo.app`). With it empty, the job fails `metadata_off`. It +downloads only from archive.org, over https, through a guarded client that +reaches public addresses only (the podcasts' `allow_networks` does not apply). +The book's files go straight into the library's folder, staged in a hidden folder +beside the book, so the library needs the room, not the data folder. See +[Add from LibriVox](librivox.md). + ## Secrets The Bridge's secrets are **never in `config.yaml`**. They are: diff --git a/docs-developers/bridge/development.md b/docs-developers/bridge/development.md index 3d9f7b77..f365daae 100644 --- a/docs-developers/bridge/development.md +++ b/docs-developers/bridge/development.md @@ -179,7 +179,10 @@ Conventions that carry the design: - the podcast jobs write a show's files in a podcast library ([Podcasts](podcasts.md)); - `audible.import` installs a backed-up title without replacing anything - ([The hardened transfer](audible.md#the-hardened-transfer-internaltransfer)). + ([The hardened transfer](audible.md#the-hardened-transfer-internaltransfer)); + - `librivox.fetch` writes a whole new book folder, staged in a dot folder beside + it and moved into place by one no-replace rename, never into an existing book + ([Add from LibriVox](librivox.md#staging-resume-and-cleanup)). A delete moves the files into `.audiosilo-trash/` first, as podcast retention does diff --git a/docs-developers/bridge/index.md b/docs-developers/bridge/index.md index a1c2c553..e713c251 100644 --- a/docs-developers/bridge/index.md +++ b/docs-developers/bridge/index.md @@ -75,6 +75,10 @@ the job kinds in the Bridge's heartbeat include it (see [`audible.sync`](audible.md#position-sync-audiblesync-and-the-push-loop) and a push loop over `GET /bridge/progress` sync listening places both ways, furthest only, for the accounts the console turns sync on for. +- **LibriVox.** [`librivox.fetch`](librivox.md) adds a LibriVox recording an admin + picked to a books library: the manifest from the community metadata service, + the sections from archive.org only, a no-replace move into place, a rescan. The + server then records the book as that recording. Some job families belong to a [managed module](../server/modules.md) of the server, which the console turns on and off. @@ -258,6 +262,9 @@ one-command worker install needs them. its matching, `audible.import` (backups: resume, the hardened transfer, secrets), position sync (`audible.sync`, the push loop), the server's accounts, titles and queueing, the `audible` module and the build-tagged fake. +- [Add from LibriVox](librivox.md) - `librivox.fetch` (its params, progress, + result and codes), the book's identity, the archive.org-only fetch, placement, + staging and resume, and `.audiosilo/librivox.json`. - [Configuration](configuration.md) - the Bridge's settings and environment, the images and the compose example. - [Development](development.md) - the gate, the layout and the Bridge's own page. diff --git a/docs-developers/bridge/librivox.md b/docs-developers/bridge/librivox.md new file mode 100644 index 00000000..88b8177c --- /dev/null +++ b/docs-developers/bridge/librivox.md @@ -0,0 +1,274 @@ +--- +title: Add from LibriVox +description: "How an admin adds a LibriVox recording to a books library: the three admin routes, the librivox.fetch job (params, progress, result, failure codes), the book's identity recorded by the server's fold, the archive.org-only fetch, placement and names, staging and resume, and .audiosilo/librivox.json." +--- + +An admin searches the community metadata database's LibriVox works, picks a +recording and a books library, and the Bridge downloads the recording into that +library. The server then records the placed book as that LibriVox recording, so +[`/meta`](../server/api/reference.md#get-apiv1librariesidmeta) answers it with no +console match. + +| Side | Role | Code | +|---|---|---| +| The server | the admin routes, the recording's identity at queue time, the job kind and its fold | `api/handlers_librivox.go`, `bridge/librivox.go`, `catalog/librivox.go`, `meta/librivox.go` | +| The Bridge | reads the manifest, downloads from archive.org, places the folder, rescans | `internal/librivoxjob`, `internal/metaops/librivox.go`, netguard's per-request `Allow` | +| The console | [Library > LibriVox](../server/web-ui.md#library--librivox) | `admin-ui/src/features/librivox/` | +| The player | [Find on LibriVox](../frontend/library-and-browse.md#find-on-librivox-admin), an admin page off a books library's menu | `src/components/librivox/` | + +The User Guide's [Add free books from LibriVox](/users/admin/librivox) is the +admin's view of it. + +## The gate + +There is no managed module, no toggle and no `/server` flag of its own. A client +offers the feature when all of these hold: + +- the caller is an admin; +- the `metadata` capability is on; +- the Bridge's features list `librivox`, the family of `librivox.fetch`. + +The server lists a feature only for kinds it knows +(see [`GET /server`](../server/api/reference.md#get-apiv1server-the-bridge-capability-and-block)), +so an older server never shows it, and an older Bridge never advertises the kind. + +The console reads the features from `GET /admin/bridge`, which keeps them while +the Bridge is offline, so it still queues a job that waits for the Bridge. The +player reads them from `GET /server`, whose `bridge` block is present only while +the Bridge is online, so the player offers nothing while it is offline. + +## The routes + +All three are admin only (`requireAdmin`) and answer `404 metadata_off` while +community metadata is off. The [reference](../server/api/reference.md#add-from-librivox) +has the shapes and every status. + +- `GET /admin/librivox/search?q=&lang=&limit=` asks metaserve's + `works/search?source=librivox`. The server checks `lang` and `limit` itself, so + a metaserve `400` never reads as an outage. +- `GET /admin/librivox/work?id=` answers the work and its LibriVox recordings only + (those with a `librivox_id`). Each recording carries `in_library`, where it + already is on this server, and `job`, the newest open fetch of the project in + any library. +- `POST /admin/librivox/fetch {library_id, librivox_id}` resolves the project's + work and recording and queues `librivox.fetch`. + +## The job: `librivox.fetch` + +| | | +|---|---| +| Kind | `librivox.fetch` (`bridge.KindLibrivoxFetch`; the Bridge's `librivoxjob.Kind`), feature `librivox` | +| Server spec | needs a library, no book path, **writes**, a books library only, internal (`POST /admin/bridge/jobs` refuses it with `400`), no module | +| Dedupe | per library and project (suffix `\|`): a repeat answers the open job | +| Notifications | not quiet. Done is "Added from LibriVox", named by the book its result names. Failed, and a lasting park, are notified as for other kinds, named by the library | +| Bridge lane | `download` (1 slot, shared with `audible.import` and an ordinary `podcast.fetch`), so one book downloads at a time | +| Bridge state | `/librivox/-.json` | + +### Params + +The server builds them and checks them strictly. The Bridge reads them leniently +and ignores unknown fields. + +```json +{ "librivox_id": 192, "work_id": "johnny-crows-garden", "recording_id": "…", "library_id": 3 } +``` + +`work_id` and `recording_id` are what the server resolved at queue time +([The book's identity](#the-books-identity)). Neither may be blank. + +### Progress + +`progress.step` is one of these, in order: + +| Step | What the Bridge does | +|---|---| +| `manifest` | reads the project's manifest and work from its metadata service | +| `download` | downloads the sections; `done` and `total` count sections | +| `cover` | downloads `cover.jpg`, best effort | +| `place` | writes `.audiosilo/librivox.json` and moves the folder into place | +| `rescan` | asks the server to index the book (`POST /bridge/rescan`, three tries) | + +The job waits while its library has no mapping check yet. A new job whose library +is checked but not mapped and writable fails `not_writable`. A resumed job, or one +already downloading, **parks** instead (`needs_attention`, park code +`not_writable`), and it carries on once a mapping check reports the library +writable again. + +### Result + +```json +{ "library_id": 3, "path": "L. Leslie Brooke/Johnny Crow's Garden", "librivox_id": 192, "files": 9, "bytes": 53477376 } +``` + +`path` is the book path the Bridge's rescan answered. `files` and `bytes` count +everything the folder holds from the job: the sections and the cover. + +### Failure codes + +A failed job's result is `{code, message}`, and its error is the message. The +message never holds a URL. + +| Code | Means | +|---|---| +| `not_writable` | the library is not a checked, writable books mapping, or its mapping moved to another mount while the job ran | +| `exists` | the title's folder and its fallback are both taken, or the author folder is a listed book or holds audio | +| `disk_full` | the library has less than 1.1 times the rest of a file free | +| `not_found` | the Bridge's metadata service has no such project, it lists no files, or the work's recording now names another project | +| `archive_unavailable` | a section is not on archive.org, or archive.org kept failing | +| `metadata_off` | the Bridge has no metadata service set (`metadata.base_url`) | + +The console words each code (`FETCH_CODES` and `fetchReason` in +`librivox-model.ts`), and so does the player (`librivox.fetch.code.*`). An +unknown failure keeps the Bridge's own message in the console's job list. + +## The book's identity + +The server, not the Bridge, decides which recording a fetched book is: + +1. `POST /admin/librivox/fetch` asks metaserve's `lookup?librivox_id=` for the + project alone (`meta.Service.LibrivoxRecordingOf`). An answer with no + recording is `404 not_found`. The server never falls back to a work's first + recording. +2. The work and recording travel in the job's params. +3. When the job ends `done`, `catalog.FoldLibrivoxFetch` (from the jobs' + `OnEnded` hook) writes the book's `book_matches` row with them: `how` + `accepted`, `set_by` the admin who asked, no run and no offered identifiers. + It wakes the `OnBookChange` listeners, since the rescan woke them before the + match was there. + +The fold writes only when the result names the job's own library and project and +a book indexed there, in a books library. Otherwise it records nothing and logs +`ErrFetchNotIndexed`. It **replaces** a row already at that path: the Bridge +placed the book in a folder that was vacant, so such a row is a deleted book's +leftover. It never touches another path's row. + +"Where is this recording" is read from those stored rows alone +(`catalog.WorkRecordingBooks`): indexed books whose `book_matches` row names that +exact, non-empty `recording_id` of the work. A work-level match, or `/meta`'s +answer for a work's first recording, never counts. The work route's `in_library` +and the fetch route's `409 in_library` (any library) both read it. + +Two things drop the identity, because the server does not read +`.audiosilo/librivox.json`: + +- a fold lost between the job's commit and the fold, such as a crash; +- [Clear community matches](../server/api/reference.md#delete-apiv1admincommunity-matches). + +Adding the recording again repairs either. When the folder the Bridge plans now, +or its fallback, is the one it placed (it holds the project's `librivox.json`), +the Bridge only rescans it, and the fold records the match. + +## Fetching from archive.org only + +The Bridge reads the manifest from **its own** metadata service +(`metadata.base_url`, `meta.audiosilo.app` by default), not the server's copy: + +- `GET /api/v1/librivox/audiobooks?id=&extended=1&coverart=1`, the + LibriVox-shaped feed, for the sections, their titles and URLs, the cover and the + archive.org identifier; +- `GET /api/v1/works/{work_id}` for the title, the authors and how many LibriVox + recordings the work has. A `404` there is no failure, and the project's own + title names the folder then. + +A metadata service that is down never ends the job. Each read is tried 4 times +(5, 10 and 20 seconds apart), then the run fails with no code and keeps its +staging folder and state for the next attempt. A definite `404` for the project +ends it as `not_found`. + +Every section and cover URL must be `https` on `archive.org` or a subdomain of it +(`AllowedURL`). The Bridge checks every section's URL before anything is fetched +or written (one elsewhere fails the job `archive_unavailable`), the cover's before +the cover is fetched (one elsewhere is skipped), and each again on every redirect, +through netguard's per-request `Allow`. It never fetches from librivox.org. The client is the Bridge's own netguard client +(public addresses only, not the podcasts' `allow_networks`). A URL with padding +around it is refused as it stands, so the check and the request see one spelling. + +Only audio is kept: + +- a `Content-Type` other than none, `audio/*`, `application/octet-stream`, + `binary/octet-stream` or `application/ogg` is never written; +- a whole file whose first bytes (past any zero padding in its first 4 KB) are no + ID3 tag, MPEG frame sync, `OggS`, `fLaC` or MP4 `ftyp` is removed; +- an empty body never counts as a finished section. + +Each is tried again like a dropped connection. Three attempts in a row that add +nothing to a section fail the job `archive_unavailable`, and so does a `4xx` +other than `408`, `416` and `429`. + +## Placement and names + +The folder is `//`, from `placement.Suggest` as a standalone book: + +- **Author**: the first credit. Over three authors, `Various`. An author folder + already in the library (`importjob.AuthorFolders`) is reused as it is spelled. + With no author the book goes at the library root. +- **Title**: the work's title, or the project's own title when the work has more + than one LibriVox recording, so "(version 2)" and "Vol. 001" keep two projects + apart. That is why it is not `importjob.Planner.Plan`, whose `match.CleanTitle` + drops them. A title with nothing left is `LibriVox <id>`. +- **Taken**: when the title's folder exists, the Bridge tries + `<Title> (LibriVox <id>)` once, then fails `exists`. + +Names lose control characters and runs of spaces, go through `placement.Sanitize` +(which also drops leading dots) and are cut to 180 bytes on a rune boundary. + +Sections are `NN - <section title>.<ext>`: + +- `NN` is the section's 1-based position, at least two digits and as wide as the + count. It is never LibriVox's `section_number`. +- A title that opens with its own number and a separator (`1 - `, `1. `, `1: `, + `1) `) loses it, so "1 - Chapter 01" is `01 - Chapter 01`. A title such as + "1984" stays. +- An untitled section, or one that was only its number, is `Section N`. +- `<ext>` is the URL's audio extension, else `.mp3`. + +Each section is one file and one chapter: the job writes no `chapters.json`, and +the scanner titles the chapters from the tags or the file names. + +## Staging, resume and cleanup + +The Bridge stages the book in `<parent>/.audiosilo-librivox-<id>/`, beside where +it goes. It is a dot folder, so the scanner skips it. Every write goes through +the mount's `os.Root`: + +1. Each section downloads to `<name>.part`. Its strong `ETag` and size are saved + in the state before the body. +2. A resumed section asks with `Range` and `If-Range`, and only a proven `206` + continues it. Anything unproven starts the file again. +3. A section is renamed to its name only at its stated size. +4. The cover (`cover.jpg`, only a sniffed JPEG from archive.org) and + `.audiosilo/librivox.json` follow. +5. The staging folder is swept to the manifest's files, then moved into place by + one `fsutil.RenameNoReplace`. A folder that raced in takes the fallback once. + Nothing is ever replaced. +6. The Bridge rescans the book. + +The mapping is checked again before each section, before the cover and marker, +and before the rename. + +The state file records the parent, the destination, the partials' validators and, +once placed, the count and bytes. So: + +- a stop, or a lost claim, keeps the staging folder and the state, and the next + attempt follows them instead of planning again; +- once placed, the state is the placement marker: a failed rescan keeps the book, + and the retry goes on from the rescan; +- a placement the state never recorded (a kill between the rename and the state's + write) is adopted when the folder or its fallback holds this project's + `librivox.json`, and is never fetched again; +- a cancel, a failure, or the runner abandoning the job removes the staging + folder, an author folder it made that is empty, and the state. + +A lost claim that the server never hands out again leaves its staging folder, +hidden and unindexed. + +## `.audiosilo/librivox.json` + +The Bridge writes it into the book's folder: + +```json +{ "version": 1, "librivox_id": 192, "work_id": "johnny-crows-garden", "recording_id": "…", "archive_id": "…" } +``` + +The server does not read it yet: its scanner skips the name (no scan error), and +it is not one of the [attachments](attachments.md) the server serves. diff --git a/docs-developers/bridge/pairing-and-jobs.md b/docs-developers/bridge/pairing-and-jobs.md index ede02553..d9e28cbb 100644 --- a/docs-developers/bridge/pairing-and-jobs.md +++ b/docs-developers/bridge/pairing-and-jobs.md @@ -123,7 +123,7 @@ pairing and after a mapping check finishes: | Field | What it carries | |---|---| | `version` | the Bridge's version | -| `kinds` | the job kinds it runs (`bridge.verify`, `sidecars.generate`, `sidecars.describe`, `chapters.detect`, `sidecars.contribute`, `meta.correct`, `readalong.align`, `audible.poll`, `audible.import` and `audible.sync`; an older Bridge lists fewer) | +| `kinds` | the job kinds it runs (`bridge.verify`, `sidecars.generate`, `sidecars.describe`, `chapters.detect`, `sidecars.contribute`, `meta.correct`, `readalong.align`, `audible.poll`, `audible.import`, `audible.sync` and `librivox.fetch`; an older Bridge lists fewer) | | `libraries` | its last [mapping check](mappings.md) of each library | | `workers` | its [ASR workers](workers.md) that have connected at least once: `name`, `version`, `platform` (the GOOS/GOARCH and the speech recognition in one line, e.g. `darwin/arm64, mlx-whisper (metal)`) and `last_seen` | | `ui_url` | its own page as the local network reaches it (its `public_url` setting), which the console links to | @@ -534,7 +534,7 @@ Bridge doesn't have. The server's table also says whether a kind **writes** into the library. Such a kind (`sidecars.generate`, `sidecars.describe`, `chapters.detect`, `readalong.align`, `podcast.subscribe`, -`podcast.poll`, `podcast.fetch`, `podcast.retain`) is queued only for a library the last heartbeat reports +`podcast.poll`, `podcast.fetch`, `podcast.retain`, `audible.import`, `librivox.fetch`) is queued only for a library the last heartbeat reports **mapped and writable**, else `409 library_not_writable`, after the dedupe (an open job is still returned). `bridge.verify`, `sidecars.contribute` and `meta.correct` write nothing. Book-writing jobs refuse a podcast library with @@ -677,6 +677,27 @@ and `audible_error`. The push back to Audible is no job: a loop reads `GET /bridge/progress` every minute. See [Position sync](audible.md#position-sync-audiblesync-and-the-push-loop). +### LibriVox jobs + +`librivox.fetch` adds one LibriVox recording to a books library. It belongs to no +module: + +- internal (`POST /admin/bridge/jobs` refuses it with `400`), queued by + `POST /admin/librivox/fetch`; +- a **writing** kind for a books library: it needs a writable mapping, else + `library_not_writable`, and a podcast library is `not_book_library`; +- no book path, since the book does not exist until the job places it; +- params `{librivox_id, work_id, recording_id, library_id}`, built by the server; +- deduped per library and project; +- **not** quiet: done is notified, named by the book its result names; a failed + or parked one names only its library, since only a done job's result names the + book. + +It runs in the `download` lane, so it takes turns with `audible.import` and the +ordinary `podcast.fetch` slot, and its partial download survives a restart. When +it ends done, the server records the book as the recording. See +[Add from LibriVox](librivox.md). + ### Notifications The server tells admins about notable jobs itself (the Bridge has no route for diff --git a/docs-developers/contributing/documentation.md b/docs-developers/contributing/documentation.md index 68d4c6d9..d689718a 100644 --- a/docs-developers/contributing/documentation.md +++ b/docs-developers/contributing/documentation.md @@ -107,6 +107,7 @@ mappings: | The player's podcasts: `src/components/podcasts/`, `src/playback/episode-fetch.ts`, Home's New episodes (`new-episodes-*`, `use-new-episodes.ts`), the `shows` Library mode, `src/lib/library-kind.ts`, the podcast client gate (`auth.PodcastPlayerMinVersion`, `app.json` `expo.version`, `client-id.ts`), the gated whole-list replace (`catalog.GatedScopes`); finding shows (`find-shows-*`, `directory-search.tsx`, `discovery-*`, `follow-by-url.tsx`, `follow-requests.tsx`, `opml-*`, `use-opml-import.ts`, `shows-actions.tsx`, `check-episodes-button.tsx`, `artwork-retry.ts`, `src/lib/podcast-requests.ts`, `findShowsHref`, the `podcast_discovery` / `podcast_opml` client methods and hooks), the server's CORS lists (`corsAllowHeaders`, `corsExposeHeaders`) | [frontend/podcasts.md](../frontend/podcasts.md) (and its [Finding shows](../frontend/podcasts.md#finding-shows)) + [bridge/podcasts.md](../bridge/podcasts.md#player-phase-2d-1) (the wire and the controller's rules; [discovery and OPML](../bridge/podcasts.md#player-discovery-and-opml-phase-2d-2) for 2d-2) + [api/index.md CORS](../server/api/index.md#cors) and [cross-repo-contract.md §12](../architecture/cross-repo-contract.md#12-cors-for-web-development) + [auth-and-security.md](../server/auth-and-security.md#podcast-visibility) + the [reference](../server/api/reference.md#podcasts) (the gate, the replace rules) + [api/index.md](../server/api/index.md#client-identification-x-audiosilo-client) + the lines in [playback.md](../frontend/playback.md), [state-and-data.md](../frontend/state-and-data.md), [offline.md](../frontend/offline.md), [chromecast.md](../frontend/chromecast.md), [end-of-book.md](../frontend/end-of-book.md) and [kids-mode.md](../frontend/kids-mode.md) + `/users/listening/podcasts` (and the podcast lines in `browsing`, `up-next`, `end-of-book`, `offline-downloads`, `chromecast`, `kids-mode`, `mobile-apps`, `/users/troubleshooting`, `/users/admin/podcasts`, `/users/admin/server#modules`, `/users/faq`) + the `web-player/library-shows.png`, `podcast-show.png`, `home-new-episodes.png`, `episode-fetching.png`, `phone-podcast-show.png`, `find-shows-search.png`, `find-shows-address.png`, `find-shows-import.png` and `phone-find-shows.png` screenshots (the podcast seed in `run.sh` and its `directory` fixture; Find shows' stand-in routes in `capture-web.mjs`) | | Managed modules: the server's `internal/modules` (its `Runner`, the `builtin` registry), `api/modules.go`, a `modules.<id>` setting, the console's Settings > Modules (`features/settings/modules-topic.tsx`), the player's `src/modules/` registry and slots | [server/modules.md](../server/modules.md) (+ the adding-a-module steps) + [configuration.md](../server/configuration.md#managed-modules-modules) (the key, the variable, the runtime table) + the [reference](../server/api/reference.md#get-apiv1adminmodules) (`GET /admin/modules`, the settings envelope's `modules`, the module's flag on `GET /server`, the module's Bridge jobs) + [api/index.md](../server/api/index.md#capability-flags---gate-your-features) (the flag, `module_off`, `kid_account`, `demo_account`) + [bridge/pairing-and-jobs.md](../bridge/pairing-and-jobs.md#jobs-of-a-managed-module) (the jobs a module owns, held while it is off) + [web-ui.md](../server/web-ui.md) + [frontend/state-and-data.md](../frontend/state-and-data.md#player-modules-srcmodules) + the [cross-repo contract](../architecture/cross-repo-contract.md#8-capability-flags-get-apiv1server) + `/users/admin/server#modules` (the console's Learn more link opens it: keep the heading) + `/users/admin/bridge#when-the-contributions-module-is-off` + the `admin/settings-modules.png` screenshot | | Audible accounts, backups and position sync (Bridge Phases 4a, 4b and 4c): the server's `catalog/audible.go`, `catalog/audible_import.go`, `catalog/audible_sync.go` (and FurthestOnly in `catalog/listening_sync.go`), `internal/audible` (the scheduler, `imports.go`), `bridge/audible.go` (`audible.poll`, `audible.import`, `audible.sync`), `api/handlers_audible.go`, `api/handlers_audible_sync.go`, `0063`, `0064`, `0065` (planned destinations, contests), `0066` (sync, `no_sync`, `progress.change_seq`), the `audible` module and flag, the console's `features/audible/` (the Audible accounts card, Settings > Audible); the Bridge's `internal/audible` (the client, the build-tagged fake and `AUDIOSILO_BRIDGE_AUDIBLE_*`), `audibleacct`, `audiblejob` (`import.go`), `audiblesync` (the job and the push loop), `link/audiblesync.go`, `0011` (`audible_push`, `refusal_kind`), `importjob`, `placement`, `transfer`, `fsutil`'s install, `link/audible.go`, `0009`, `0010` (the device profile), `redact.go`, `audiblejob/plan.go`, the Accounts tab's `AudibleCard`, the `audible` setup item | [bridge/audible.md](../bridge/audible.md) + [bridge/configuration.md](../bridge/configuration.md#audible) (and its Secrets) + [bridge/development.md](../bridge/development.md) (the tagged gate) + [pairing-and-jobs.md](../bridge/pairing-and-jobs.md#audible-jobs) (and its setup checklist) + the [reference](../server/api/reference.md#audible-accounts) (and 4c's [position sync](../server/api/reference.md#audible-position-sync) routes, the reserved `audible:` device id on `/me/listening/sync` and the progress `PUT`, and the module flag on `GET /server`, `features`, `GET /admin/modules`) + [api/index.md](../server/api/index.md#capability-flags---gate-your-features) (the flag, the codes) + [server/modules.md](../server/modules.md) + [configuration.md](../server/configuration.md#managed-modules-modules) + [data-model.md](../server/data-model.md#the-bridge) (`0063`, `0064`, `0065`, `0066`; `progress.change_seq`) + [web-ui.md](../server/web-ui.md) + the [cross-repo contract](../architecture/cross-repo-contract.md#16-the-bridge) (after the workspace `CROSS-REPO.md` §8, §23 and §25) + `/users/admin/audible` (its sync section), `/users/listening/playback` (Places from Audible), `/users/getting-started/bridge-accounts#audible`, `/users/admin/bridge` (the card, the setup step), `/users/admin/server#modules` + the `admin/audible-accounts-card.png`, `admin/audible-titles.png`, `admin/audible-backup-settings.png`, `admin/audible-backup-all.png`, `admin/audible-destination.png`, `bridge/accounts-audible.png` and `bridge/audible-sign-in.png` screenshots (`capture-bridge.mjs` against a Bridge built with `-tags audiblefake` and the fake: `SHOTS_ONLY=bridge/,admin/audible screenshots/run.sh`). Copy about backing up says "a personal backup of books you bought" and "check your local law", never that it is legal everywhere | +| Add from LibriVox: the server's `api/handlers_librivox.go`, `bridge/librivox.go` (`librivox.fetch`), `catalog/librivox.go` (`FoldLibrivoxFetch`, `WorkRecordingBooks`), `meta/librivox.go`, the console's `features/librivox/` (and the job list's `fetchReason`, the Community card's LibriVox line, the bell); the Bridge's `internal/librivoxjob`, `metaops/librivox.go`, netguard's per-request `Allow`; the player's `src/components/librivox/` | [bridge/librivox.md](../bridge/librivox.md) + the [reference](../server/api/reference.md#add-from-librivox) (and `community_match`, the internal kinds of `POST /admin/bridge/jobs`, the `librivox` feature) + [api/index.md](../server/api/index.md#capability-flags---gate-your-features) (the feature, `in_library` and the other codes) + [bridge/pairing-and-jobs.md](../bridge/pairing-and-jobs.md#librivox-jobs) + [bridge/attachments.md](../bridge/attachments.md) and [server/scanner.md](../server/scanner.md#the-bridges-attachments) (`librivox.json`) + [data-model.md](../server/data-model.md#book_matches) (the fold) + [web-ui.md](../server/web-ui.md#library--librivox) + [frontend/library-and-browse.md](../frontend/library-and-browse.md#find-on-librivox-admin) and [routes.md](../frontend/routes.md) + [backups-and-notifications.md](../server/backups-and-notifications.md#bridge-messages) + the [cross-repo contract](../architecture/cross-repo-contract.md#librivox-fetches) (after the workspace `CROSS-REPO.md` §23 and §17) + `/users/admin/librivox` and the `admin/librivox.png` and `web-player/find-librivox.png` screenshots | | Chapterless books: the Bridge's `internal/chapterdetect`, `chapter_detecting`, the windows manifest, `chapters.detect`; the server's `metadata/chapterlist.go`, `catalog/generatedchapters.go`, the console's Detect chapters (`health/detect-chapters-dialog.tsx`) and the Chapters card's source control; the player's generated caption | [bridge/chapters.md](../bridge/chapters.md) + [bridge/attachments.md](../bridge/attachments.md#chaptersjson) + [bridge/pairing-and-jobs.md](../bridge/pairing-and-jobs.md#kinds-are-allow-listed-twice) (the kinds table) + [server/community-chapters.md](../server/community-chapters.md#generated-chapters) + the [reference](../server/api/reference.md#post-apiv1adminbridgejobs) (`chapters_source`, `chapter_choice`, `generated_chapters_fit`, the `409` codes) + [api/index.md](../server/api/index.md#capability-flags---gate-your-features) (the `chapters` feature, the codes) + [data-model.md](../server/data-model.md) + [backups-and-notifications.md](../server/backups-and-notifications.md#events) (`chapters_source` on the event) + [web-ui.md](../server/web-ui.md) (Health, the Chapters card) + [frontend/book-page.md](../frontend/book-page.md#the-chapters-tab) (the caption) + `/users/admin/generating-characters-and-recaps#books-without-chapters`, `/users/admin/health`, `/users/admin/books#chapters-found-in-the-narration`, `/users/listening/book-page#chapters` + the `admin/health-detect-chapters.png`, `admin/detect-chapters-dialog.png` and `admin/book-chapters-generated.png` screenshots | | Read-along: the Bridge's `internal/readalong`, `internal/epubcfi`, `readalong_aligning`, goal `readalong`, `readalongjob.go` (the reuse table, the 50% refusal); the server's `metadata/readalong.go`, `library/readalong.go`, `catalog/readalong.go`, `handlers_readalong.go`, the `readalong.align` kind, `Book.readalong`, the `readalong` capability; the console's Read-along card (`features/book/readalong-*`) | [bridge/readalong.md](../bridge/readalong.md) + [bridge/attachments.md](../bridge/attachments.md#readalongjson) + [bridge/pairing-and-jobs.md](../bridge/pairing-and-jobs.md#kinds-are-allow-listed-twice) (the kinds table) + the [reference](../server/api/reference.md#get-apiv1librariesidreadalong) (`/readalong`, `Book.readalong` on `/item`, the estimate's `kind=readalong.align`, the job, the Bridge book route's `duration` and `ebook`) + [api/index.md](../server/api/index.md#capability-flags---gate-your-features) (the capability, the feature, `no_readalong`, `readalong_stale`, `readalong_open`, `no_ebook`) + [scanner.md](../server/scanner.md#a-books-ebook) (ebook hashing) + [data-model.md](../server/data-model.md) (`0058`) + [backups-and-notifications.md](../server/backups-and-notifications.md#events) + [web-ui.md](../server/web-ui.md) (the card) + the [cross-repo contract](../architecture/cross-repo-contract.md#18-the-ebook-reader) (after the workspace `CROSS-REPO.md` §23 and §24) + `/users/admin/making-read-along`, `/users/admin/bridge` (the job), `/users/admin/notifications` + the `admin/book-readalong.png` and `admin/readalong-dialog.png` screenshots (`SHOTS_ONLY=admin/book-readalong,admin/readalong-dialog`) | | Read-along in the player (Bridge Phase 3c-2): `reader/src/readalong.js`, the read-along messages in `protocol.ts` and `reader.js`, `src/reader/` (`readalong-map.ts`, `readalong-gating.ts`, `use-book-readalong.ts`, `narration.ts`, `use-reader-readalong.ts`, `reader-session.ts`, `page-edge.tsx`, Follow narration in `reader-screen.tsx`), the companion's Text tab (`companion/text-panel.tsx`, `use-text-tab.ts`), Make read-along (`src/components/bridge/make-readalong.tsx`, `use-make-readalong.ts`, `readalong-model.ts`), the map in downloads (`src/downloads/readalong-file.ts`, `readalongGeneratedAt`), `useReadalong` / `useReadalongEstimate` | [frontend/reader.md](../frontend/reader.md#read-along-following-the-narration) + [player-ui.md](../frontend/player-ui.md#the-text-tab-read-along) + [offline.md](../frontend/offline.md#the-read-along-map-in-downloads) + [state-and-data.md](../frontend/state-and-data.md#read-along-usereadalong-and-usebookreadalong) + `/users/listening/reading-the-ebook#follow-the-narration`, `/users/listening/companion#text`, `/users/listening/full-player`, `/users/listening/offline-downloads`, `/users/admin/making-read-along#from-the-player` + the `web-player/player-text.png`, `reader-readalong.png`, `phone-player-text-chip.png` and `phone-player-text.png` screenshots (`SHOTS_ONLY=web-player/player-text,web-player/reader-readalong,web-player/phone-player-text`; the seed is `run.sh` 2e, `seed-readalong.mjs` and `fixtures/call-of-the-wild.readalong.json`, and the run stops early when Gutenberg's EPUB no longer matches the map) | diff --git a/docs-developers/frontend/library-and-browse.md b/docs-developers/frontend/library-and-browse.md index 85af1ed1..6bbc0e25 100644 --- a/docs-developers/frontend/library-and-browse.md +++ b/docs-developers/frontend/library-and-browse.md @@ -114,6 +114,84 @@ A capability that is still unknown hides its item. Remove download asks first through the shared `RemoveDownloadConfirm` (see [Offline](offline.md#lifecycle)). +## Find on LibriVox (admin) + +`src/components/librivox/` is the player's side of +[Add from LibriVox](../bridge/librivox.md): an admin searches the community +metadata's LibriVox works and adds a recording to a books library, which the +server's Bridge downloads. The console's Library > LibriVox is the same page, so +the two keep the same rules. + +**The gate.** `librivoxAccess(user, server)` (`librivox-model.ts`) needs an admin +(`isBridgeAdmin`), the `bridge` flag with `librivox` in `bridge.features`, and +`metadata`. `mayAddLibrivox` is its `allowed`. Unlike the console, the player +offers nothing while the Bridge is offline (see +[The gate](../bridge/librivox.md#the-gate)). Without `allowed` the page shows only +a sentence (`notAdmin`, `unavailable`, `metadataOff`): the search and its hooks +never mount. + +**The way in.** `LibrivoxMenu` is a **...** menu in the Books mode's controls +(`books-controls.tsx`'s trailing slot: the sub-nav row on a tablet or desktop, +the count line on a phone) with one item, **Find on LibriVox**. It renders +nothing for anyone else. It opens `/find-librivox?connection=&library=` +(`findLibrivoxHref`, `openFindLibrivox`), the Library tab's own page like Add +shows. It is a browse page (`BROWSE_PATHS`), so opening it keeps the Library's +scroll memory. + +**The wire** (`client.ts`, hand-mirrored from `handlers_librivox.go`): + +| Method | Route | +|---|---| +| `librivoxSearch(q, {lang, limit})` | `GET /admin/librivox/search` | +| `librivoxWork(id)` | `GET /admin/librivox/work` | +| `librivoxFetch(lib, id)` | `POST /admin/librivox/fetch`, `202 {job}` | +| `bridgeJob(id)` | `GET /admin/bridge/jobs/{id}` | + +`addRefusalKey` and `readErrorKey` word the refusals: `in_library` (the work read +again shows where; the body's `library_id` and `path` are not read), +`not_book_library`, `kind_unavailable`, `library_not_writable`, `metadata_off`, +`not_found` and, for a read, `invalid`. + +**The hooks** (`hooks.ts`): + +- `useLibrivoxSearch` and `useLibrivoxWork` are `metadata` reads with + `retry: false`. +- `useAddLibrivox` is a `metadata` write. It seeds the job's own read and asks the + connection's works again whatever the answer. +- `useLibrivoxJob` is a `bridge` read, and Cancel is `useCancelBridgeJob`. + `storeJob` puts a job about no book at its own read (`qk.bridgeJob`). + +A `librivox.fetch` has no book path, so a row follows it by its id, not through +`useBridgeJobs` or `useBridgeJobSurface`, which are keyed on a book. It is polled +while open and the page is focused (`librivoxJobPollInterval`: 5 s, 60 s while +parked) and never after a `404`. A first read that failed with no answer or a +server error is asked again every 60 s; a refusal (`400`, `401`, `403`) is not. + +**The page.** One scroller holds the search (asked on submit only), the works +found, and the opened work's recordings under it. + +- Add goes to the library the page was opened from, or the one picked when the + server has several books libraries. Podcast libraries are never offered. +- A row follows the last job it queued or saw open on the work + (`useFollowedFetch`) and switches only when the work names a different job, so a + failure still says why and an older answer never takes the row back from the + job it just queued. +- `fetchView` words the job: its step (`manifest`, `download`, `cover`, `place`, + `rescan`) or its code (`librivox.fetch.code.*`). The Bridge's English is never + shown. +- A job's end asks the works again. A `done` one asks once more after + `DONE_RECHECK_MS` (the server records the book just after the job ends) and reads + the library's lists and the connection's recently added again + (`invalidateLibraryLists`). +- A read with nothing to show and no network is `OfflineState` + (`useUnreadQuery`). A search or a work read again that failed keeps what it + shows. +- The recording's `cover_url` is never loaded: it is a third-party host, with no + server proxy. + +Strings are under `librivox.*` in all six locales, and fixtures are in +`src/testing/librivox-fixtures.ts`. + ## Browse building blocks The covers and shelves every browse screen is made of (STYLEGUIDE section 8) diff --git a/docs-developers/frontend/routes.md b/docs-developers/frontend/routes.md index 02cd9652..e61042ad 100644 --- a/docs-developers/frontend/routes.md +++ b/docs-developers/frontend/routes.md @@ -30,6 +30,7 @@ The layouts and the files that are not pages are in | `/search` | `(app)/(search)/search.tsx` → `src/components/search/search-screen.tsx` | Search across the person listening's connections. See [Search](#search). | | `/library?mode=…` | `(app)/(library)/library/index.tsx` → `src/components/library/library-screen.tsx` | The Library tab root, with the browse modes `books` (the mode when `mode` is absent), `authors`, `series`, `narrators`, `collections` and `folders` (see [The Library modes](library-and-browse.md#the-library-modes)). Books mode also reads `sort` (`recent\|title\|author\|length`), `status` (`new\|progress\|finished`), `dl=1` and `len` (`short\|mid\|long`), which Home's links set. | | `/find-shows?connection=…&library=…§ion=…` | `(app)/(library)/find-shows.tsx` → `src/components/podcasts/find-shows-screen.tsx` | Add shows to a podcast library with a search, a feed address or an OPML import (see [Podcasts](podcasts.md#finding-shows)). It is the Library tab's own page, so a cold link (a reload, a pasted URL) lands in the Library and Back returns there. | +| `/find-librivox?connection=…&library=…` | `(app)/(library)/find-librivox.tsx` → `src/components/librivox/find-librivox-screen.tsx` | Find on LibriVox, an admin's page that adds a LibriVox recording to a books library through the Bridge (see [Find on LibriVox](library-and-browse.md#find-on-librivox-admin)). Like Add shows, it is the Library tab's own page. | | `/series?connection=…&library=…&name=…&work=…` | `(app)/(home,library,search,offline,me)/series.tsx` → `src/components/library/series-screen.tsx` | The series bookcase. `name` is a **local** series: the exact `Book.series` value, the `series=` filter of `GET /books`. `work` is a community work id whose rails to show, which is how the listener reaches a series they own nothing of. The route needs at least one of them. With both, `name` lists the owned books and `work` supplies the reading orders and ghosts. | | `/author?connection=…&library=…&name=…`, `/narrator?…` | `author.tsx` / `narrator.tsx` (same group) → `src/components/library/person-screen.tsx` | A person page: the books whose `Book.author` or `Book.narrator` is **exactly** `name` (a joint credit is one name, as the server lists it), series shelves, other books, and Read by / Books by chips. A narrator filter needs `browse_people`. An older server shows a "can't list books by narrator yet" state. | | `/collection?connection=…&id=…` | `collection.tsx` (same group) → `src/components/library/collection-screen.tsx` | A collection. It belongs to a server, not a library, so its items can span libraries. The owner has Edit, Share, Delete, and Move up, Move down and Remove in the list view. A viewer (`owned: false`) has Leave. A `404` reads as deleted or no longer shared. | diff --git a/docs-developers/frontend/state-and-data.md b/docs-developers/frontend/state-and-data.md index 4a751aa0..2f4ad1cc 100644 --- a/docs-developers/frontend/state-and-data.md +++ b/docs-developers/frontend/state-and-data.md @@ -556,6 +556,8 @@ older server (React Query rejects it instead), and the query stays pending. | `communityCoverUrl(lib, path, coverUrl, { size })` | `useCommunityCover(cid, lib, path)` (a resolver, not a query) | `meta_covers` | `GET /libraries/{id}/meta/cover?path=&url=&size=` (see [Community covers](#community-covers-clientcommunitycoverurl)) | | `readalong(lib, path)` | `useReadalong(lib, path, { enabled, generatedAt }, connectionId?)`, behind `useBookReadalong` | `readalong` | `GET /libraries/{id}/readalong` (the map, checked span by span into a `CheckedReadalongMap`; see [Read-along](#read-along-usereadalong-and-usebookreadalong)) | | `readalongEstimate(lib, path)` | `useReadalongEstimate(lib, path, connectionId?)` | `bridge` | `GET /admin/bridge/estimate?kind=readalong.align` (`ReadalongEstimate`, admin only) | +| `librivoxSearch(q, { lang, limit })` / `librivoxWork(id)` | `useLibrivoxSearch` / `useLibrivoxWork` | `metadata` (the page checks the whole gate; see [Find on LibriVox](library-and-browse.md#find-on-librivox-admin)) | `GET /admin/librivox/search`, `/admin/librivox/work` (admin only) | +| `librivoxFetch(lib, id)` / `bridgeJob(id)` | `useAddLibrivox` / `useLibrivoxJob` | `metadata` / `bridge` | `POST /admin/librivox/fetch`, `GET /admin/bridge/jobs/{id}` (admin only) | - **People lists are normalised.** The server answers `{ authors, unknown }` and `{ narrators, unknown }`. The client returns both as one shape, diff --git a/docs-developers/server/api/index.md b/docs-developers/server/api/index.md index 231f8f36..0b0fd03b 100644 --- a/docs-developers/server/api/index.md +++ b/docs-developers/server/api/index.md @@ -327,12 +327,12 @@ Failures a person can fix also carry a machine-readable **`code`** next to | `book_not_found` | `404` | the Bridge's `PUT /bridge/libraries/{id}/enrichment` for no book of a books library at the path; an admin catalog call on a path that is not an indexed book (book page, edit, bulk edit, match, cover upload, community cover); an admin progress edit on a path with no progress and no indexed book; `POST /admin/bridge/jobs` and `GET /admin/bridge/estimate` on a path that is not an indexed book | | `invalid_override` | `400` | a metadata edit the server refuses; the body also carries a `field` key naming the offending field (`PATCH /admin/libraries/{id}/book`, `POST /admin/books/bulk`) | | `module_off` | `404` | any route a [managed module](../modules.md) mounts, while that module is off (Settings > Modules), for a caller the route's auth check admits (it runs first). The route exists either way, so turning the module on needs no restart. The `podcasts` module mounts `POST`/`DELETE /podcasts/follow`, `POST /podcasts/check`, the `/podcasts/discovery/*` routes and `POST /podcasts/opml/import`. Also `POST /admin/bridge/jobs` for a job the module owns (`sidecars.contribute`, `meta.correct`, `sidecars.generate` with `contribute: "issue"`, the `contributions` module), checked before anything else about the job | -| `metadata_off` | `404` | a community match search, a `POST /admin/books/works` work-id batch, a community cover (`POST /admin/meta/covers`, `PUT /admin/libraries/{id}/cover/community`), or the metadata mirror (`GET /admin/meta/mirror`, `POST /admin/meta/mirror/check`) while community metadata is turned off; also [`GET /me/upcoming`](reference.md#get-apiv1meupcoming), [`GET`](reference.md#get-apiv1meupcomingcalendar)/[`POST /me/upcoming/calendar`](reference.md#post-apiv1meupcomingcalendar), [`/me/upcoming/releases.ics`](reference.md#get-apiv1meupcomingreleasesics) and [`/me/upcoming/cover`](reference.md#get-apiv1meupcomingcover) (checked after `kid_account` and, on the mint, the `demo_account` and API-key refusals; before `upcoming_off`). Never `DELETE /me/upcoming/calendar` | +| `metadata_off` | `404` | the [Add from LibriVox](reference.md#add-from-librivox) routes, a community match search, a `POST /admin/books/works` work-id batch, a community cover (`POST /admin/meta/covers`, `PUT /admin/libraries/{id}/cover/community`), or the metadata mirror (`GET /admin/meta/mirror`, `POST /admin/meta/mirror/check`) while community metadata is turned off; also [`GET /me/upcoming`](reference.md#get-apiv1meupcoming), [`GET`](reference.md#get-apiv1meupcomingcalendar)/[`POST /me/upcoming/calendar`](reference.md#post-apiv1meupcomingcalendar), [`/me/upcoming/releases.ics`](reference.md#get-apiv1meupcomingreleasesics) and [`/me/upcoming/cover`](reference.md#get-apiv1meupcomingcover) (checked after `kid_account` and, on the mint, the `demo_account` and API-key refusals; before `upcoming_off`). Never `DELETE /me/upcoming/calendar` | | `upcoming_off` | `404` | the same upcoming routes as `metadata_off` while community metadata is on but the upcoming releases job is switched off (`upcoming.enabled`, the console's Settings > Metadata) | | `too_large` | `400` / `413` | a bulk edit or an issue ignore over 1000 books, a cover batch over 60, a community cover batch over 12, or a work-id batch over 100 (`400`); a custom cover upload over 5 MiB, or a community cover over 16 MiB or 40 megapixels (`413`); a [listening sync](reference.md#post-apiv1melisteningsync) body over 1 MiB (`413`) | | `unsupported_image` | `415` | a custom cover (uploaded or community) that is not a JPEG, PNG or WebP image | | `cover_unavailable` | `502` | `PUT /admin/libraries/{id}/cover/community`, `GET /libraries/{id}/meta/cover` or [`GET /me/upcoming/cover`](reference.md#get-apiv1meupcomingcover) when the server couldn't fetch the image (the latter two also for a minute after a failed fetch, without asking again) | -| `not_found` | `404` | an [unknown API route](#error-envelope-and-status-conventions) (below), `DELETE /admin/libraries/{id}` for a library that doesn't exist, and [`GET /me/upcoming/cover`](reference.md#get-apiv1meupcomingcover) for a work the caller's upcoming list doesn't hand out a cover for (not listed for them, or listed without one) | +| `not_found` | `404` | an [unknown API route](#error-envelope-and-status-conventions) (below), `DELETE /admin/libraries/{id}` for a library that doesn't exist, and [`GET /me/upcoming/cover`](reference.md#get-apiv1meupcomingcover) for a work the caller's upcoming list doesn't hand out a cover for (not listed for them, or listed without one); also `GET /admin/librivox/work` for an unknown work and `POST /admin/librivox/fetch` for a LibriVox project the community metadata doesn't know | | `invalid_schedule` | `400` | a library `scan_schedule` that isn't `""`, `every:<N>h` (1, 3, 6, 12, 24) or `daily:HH:MM` (`POST`/`PATCH /admin/libraries`) | | `invalid_pattern` | `400` | a library `ignore_patterns` list the server refuses: more than 100 patterns, one over 200 bytes, one that matches nothing, or a malformed wildcard; the message names the line (`POST`/`PATCH /admin/libraries`, `POST /admin/setup/preview`) | | `invalid_metadata_source` | `400` | a library `metadata_source` other than `"tags"` or `"path"` (`POST`/`PATCH /admin/libraries`) | @@ -371,13 +371,14 @@ Failures a person can fix also carry a machine-readable **`code`** next to | `self_managed` | `409` | `PATCH /admin/users/{id}` assigning a person to themselves | | `has_members` | `409` | `PATCH /admin/users/{id}` assigning a person who has household members of their own | | `invalid_keep_members` | `400` | `DELETE /admin/users/{id}?keep_members=` with an entry that isn't one of that person's members | -| `kind_unavailable` | `409` | `POST /admin/bridge/jobs` (or `GET /admin/bridge/estimate`) for a kind the paired Bridge doesn't run, or before any Bridge has reported | -| `library_not_writable` | `409` | podcast follow/check, or `POST /admin/bridge/jobs` for a kind that writes (`sidecars.generate`, `sidecars.describe`, `chapters.detect`, `readalong.align`) in a library the Bridge's last heartbeat doesn't report mapped and writable | +| `kind_unavailable` | `409` | `POST /admin/bridge/jobs` (or `GET /admin/bridge/estimate`) for a kind the paired Bridge doesn't run, or before any Bridge has reported; `POST /admin/librivox/fetch` with no Bridge paired, or one that doesn't run `librivox.fetch` | +| `library_not_writable` | `409` | podcast follow/check, `POST /admin/librivox/fetch`, or `POST /admin/bridge/jobs` for a kind that writes (`sidecars.generate`, `sidecars.describe`, `chapters.detect`, `readalong.align`) in a library the Bridge's last heartbeat doesn't report mapped and writable | | `cannot_contribute` | `409` | `POST /admin/bridge/jobs` for `sidecars.generate` with `contribute: "issue"`, `sidecars.contribute` or `meta.correct` while the Bridge's last heartbeat doesn't say it can contribute (its `setup.can_contribute`, else `estimates.sidecars.can_contribute`) | | `generate_open` | `409` | `POST /admin/bridge/jobs` for `sidecars.describe`, `chapters.detect`, `sidecars.contribute` or `readalong.align` while a `sidecars.generate` job is open for the book (it writes the description, finds a chapterless book's chapters, and replaces the characters and recaps a share would file, itself; a read-along waits because both transcribe the same audio) | | `contribute_open` | `409` | `POST /admin/bridge/jobs` for `sidecars.generate` while a `sidecars.contribute` job is queued or working for the book (a parked one, or a queued one held while the `contributions` module is off, is cancelled instead) | | `not_edited` | `400` | `POST /admin/bridge/jobs` for `meta.correct` with a change whose field isn't an edit made on this server, or whose corrected value isn't the book's value now; `field` names it | | `detect_open` | `409` | `POST /admin/bridge/jobs` for `sidecars.generate` while a `chapters.detect` job is queued or working for the book (the generate job after it reuses its transcription), or for `sidecars.describe` or `readalong.align` while one is open | +| `in_library` | `409` | `POST /admin/librivox/fetch` for a LibriVox recording already on this server, in any library; the body also carries the first book's `library_id` and `path` | | `readalong_open` | `409` | `POST /admin/bridge/jobs` for `sidecars.generate` while a `readalong.align` job is queued or working for the book (a parked one is cancelled instead), or for `chapters.detect` while one is open: all three transcribe the same audio on the Bridge | | `no_ebook` | `409` | `POST /admin/bridge/jobs` for `readalong.align` for a book the index holds no ebook for | | `no_readalong` | `404` | [`GET /libraries/{id}/readalong`](reference.md#get-apiv1librariesidreadalong) for a book with no read-along map (none, one that fails the server's checks, no ebook, a part's path, a podcast library) | @@ -832,6 +833,7 @@ families the Bridge runs that the server knows: - `chapters`, for finding a chapterless book's chapters; - `meta`, for filing corrections; - `readalong`, for making read-along maps; +- `librivox`, for adding LibriVox recordings to a books library; - `contribute`, while the Bridge can file contributions to the community database. That means it has a GitHub token that GitHub accepts, from its Accounts sign-in, its environment or `gh`. The server reads this from the @@ -853,6 +855,9 @@ Check the flag **and** the feature you need: `features`. - The book page's Read-along card offers **Make read-along** only with `readalong`. +- The console's Library > LibriVox and the player's **Find on LibriVox** add a + book only with `librivox` and `metadata`, for an admin. The player also needs + `bridge`, so it offers nothing while the Bridge is offline. ### Managed modules diff --git a/docs-developers/server/api/reference.md b/docs-developers/server/api/reference.md index fa7e16bc..33431ca6 100644 --- a/docs-developers/server/api/reference.md +++ b/docs-developers/server/api/reference.md @@ -6908,10 +6908,11 @@ book path). Omitted when the book has none. There is no backfill, so a book matched before 2.6.0 has none until it is matched again. Its fields: - `work_id` and `recording_id` (omitted for none): what an accept or a match - run's apply took; + run's apply took, or the recording a book was + [added from LibriVox](#add-from-librivox) as; - `how`: `"auto"` for a run's confident item applied unasked, `"accepted"` for - the match dialog or a review item the admin included. A refresh that heals a - merged work keeps it; + the match dialog, a review item the admin included, or a book added from + LibriVox. A refresh that heals a merged work keeps it; - `matched_at`; - `web_url`: the work's page on the community site, omitted when no service is configured. @@ -10087,6 +10088,7 @@ that read the block, ask with their token. `readalong.align` gives `readalong`. - The podcast kinds give `podcast`, and `audible.poll` and `audible.import` give `audible`. + - `librivox.fetch` gives `librivox`. - `contribute` is added while the Bridge can file contributions (the last heartbeat's `setup.can_contribute`, else its `estimates.sidecars.can_contribute`). The `contributions` @@ -10355,11 +10357,12 @@ reads; a private feed URL appears only in the Bridge's claim answer. See ### `POST /api/v1/admin/bridge/jobs` *Admin.* Queues a job. `podcast.subscribe`, `podcast.poll`, `podcast.fetch`, -`podcast.retain`, `audible.poll` and `audible.import` are server-internal and -refused here with `400` (no code): use listener follow/check, the admin storage -routes, [Check now](#post-apiv1adminaudibleaccountsaccountpoll), -[Back up](#post-apiv1adminaudibletitlesaccountasinbackup) or the scheduler -instead. +`podcast.retain`, `audible.poll`, `audible.import`, `audible.sync` and +`librivox.fetch` are server-internal and refused here with `400` (no code): use +listener follow/check, the admin storage routes, +[Check now](#post-apiv1adminaudibleaccountsaccountpoll), +[Back up](#post-apiv1adminaudibletitlesaccountasinbackup), +[Add from LibriVox](#post-apiv1adminlibrivoxfetch) or the scheduler instead. Book-writing kinds on a podcast library return `409 not_book_library`; podcast kinds are only ever queued for a podcast library (follow and check answer `409 not_podcast_library` for a books library before queuing). @@ -11693,6 +11696,129 @@ go, its open poll and sync are cancelled, and so are its open backups. It signs Amazon: that is the Bridge's. `204`, known or not; audited as `bridge.audible.remove` (with `account`) when there was one. A bridge token is `401`. +## Add from LibriVox + +The code is in `api/handlers_librivox.go`, `meta/librivox.go`, +`bridge/librivox.go` and `catalog/librivox.go`. The design is in +[Add from LibriVox](../../bridge/librivox.md). An admin searches the community +metadata database's LibriVox works, opens one, and adds a recording to a books +library through the Bridge's `librivox.fetch`. The server never asks +librivox.org. + +All three routes are *Admin*: an admin's session or personal API key. A +listener's session or API key is `403`; no token, and a bridge, cast or feed +token, is `401`. They answer **`404 metadata_off`** while community metadata is +off, before anything in the request is checked, and `502` (`metadata service unavailable`, no code) when +the metadata service can't answer. There is no managed module and no `/server` +flag: a client offers them when the Bridge's `features` list `librivox` (see +[`GET /server`](#get-apiv1server-the-bridge-capability-and-block)). + +### `GET /api/v1/admin/librivox/search` + +The works with a LibriVox recording that match `q`, best first, from metaserve's +`works/search?source=librivox`. + +| Param | Meaning | +|---|---| +| `q` | required; the title or author to look for, at most 300 characters | +| `lang` | optional; one language tag (`en`, `pt-br`), sent lowercased | +| `limit` | optional; 1 to 50, default 20 | + +```json +{ + "results": [ + { + "work_id": "johnny-crows-garden", + "title": "Johnny Crow's Garden", + "authors": [{ "id": "l-leslie-brooke", "name": "L. Leslie Brooke" }], + "language": "en", + "release_date": "1903" + } + ] +} +``` + +`release_date` is always a string, `""` when unknown. + +| Status | Meaning | +|---|---| +| `200` | the results (`[]` for none) | +| `400` | `q` missing or too long; `lang` not a language tag; `limit` outside 1 to 50 | +| `404` | `metadata_off` | +| `502` | the metadata service can't answer | + +### `GET /api/v1/admin/librivox/work` + +One work and its **LibriVox recordings only** (those with a project id), by +`?id=<work_id>`. + +```json +{ + "work": { "id": "johnny-crows-garden", "title": "Johnny Crow's Garden", "authors": [{ "id": "…", "name": "…" }], "language": "en" }, + "recordings": [ + { + "recording_id": "…", + "librivox_id": 192, + "narrators": [{ "id": "…", "name": "…" }], + "runtime_min": 12, + "chapter_count": 3, + "cover_url": "https://archive.org/…jpg", + "url": "https://librivox.org/…", + "in_library": [{ "library_id": 3, "path": "L. Leslie Brooke/Johnny Crow's Garden" }], + "job": { "id": 41, "state": "running" } + } + ] +} +``` + +- `in_library` lists the indexed books whose recorded community match names this + exact recording (`[]` for none). A work-level match never counts. See + [The book's identity](../../bridge/librivox.md#the-books-identity). +- `job` is the newest open `librivox.fetch` of the project, in any library, or + `null`. A lease that ran out is settled first, so a fetch that failed on its last + attempt is not listed. + +| Status | Meaning | +|---|---| +| `200` | the work and its recordings | +| `400` | `id` missing or not a work id | +| `404` | `not_found` - no such work; `metadata_off` | +| `502` | the metadata service can't answer | + +### `POST /api/v1/admin/librivox/fetch` + +Adds a LibriVox project to a books library: + +```json +{ "library_id": 3, "librivox_id": 192 } +``` + +The server resolves the project's work and recording with metaserve's +`lookup?librivox_id=`, and queues `librivox.fetch` with them in its params. A +lookup that names no recording is `not_found`: the server never takes a work's +first recording instead. + +| Status | Meaning | +|---|---| +| `202` | `{ "job": Job }`: the queued [job](#the-job-object), or the open one for the same library and project | +| `400` | a malformed body, or `library_id` or `librivox_id` missing | +| `404` | `library not found` (no code); `not_found` - no such project in the community metadata; `metadata_off` | +| `409` | `in_library` - the recording is already on this server, in any library, with the first book's `library_id` and `path` in the body; `not_book_library` - a podcast library; `kind_unavailable` - no Bridge is paired, or the paired one doesn't run `librivox.fetch`; `library_not_writable` - the Bridge's last heartbeat doesn't report the library mapped and writable | +| `502` | the metadata service can't answer | + +An offline Bridge still gets the job queued, and it waits. A queued job is audited +as `bridge.job` (with `job`, `kind`, `library` and `librivox_id`). + +### The `librivox.fetch` job + +Internal (refused by `POST /admin/bridge/jobs` with `400`), a writing kind for a +books library, no module, deduped per library and project, and notified when it +ends done (named by the book its result names), failed or parked (named by its +library). Its params, progress steps, result and failure codes are in [The job](../../bridge/librivox.md#the-job-librivoxfetch). +A done job's result becomes the book's recorded community match (`how` +`accepted`, `set_by` the admin who asked) when it names a book indexed in the +job's own library. + ## Well-known Native deep-link association files. Both are *Public*, config-driven diff --git a/docs-developers/server/backups-and-notifications.md b/docs-developers/server/backups-and-notifications.md index 99abb0aa..5bd839bd 100644 --- a/docs-developers/server/backups-and-notifications.md +++ b/docs-developers/server/backups-and-notifications.md @@ -394,22 +394,24 @@ its title, else its path, else its library. | `sidecars.contribute` that filed nothing because the community already had it (`contributed` empty, `covered` not) | `Already in the community: <title>` | | | `meta.correct` | `Corrections sent: <title>` | "The book's corrections went to the community for review. The book page links them." | | `readalong.align` | `Read-along made: <title> (N% lined up)`. The coverage is rounded down to a whole percent, so 0.996 reads 99%. | "It is saved with the book. Open the book in a compatible AudioSilo player to follow the text with the narration." | +| `librivox.fetch` | `Added from LibriVox: <title>`, the book its result names | "It is in the library now, matched to its LibriVox recording." | | `bridge.verify` | `Bridge check finished: <library>`, or the book's title when the job named one | "The Bridge finished the job." | | any other kind | `<kind> finished: <title>` | "The Bridge finished the job." | -In the bell, only `sidecars.generate`, `sidecars.describe`, `chapters.detect` -and `readalong.align` have titles of their own (`FINISHED_OWN` in -`lib/server-events.ts`). A read-along reads `Read-along made for <name>`, with +In the bell, only `sidecars.generate`, `sidecars.describe`, `chapters.detect`, +`readalong.align` and `librivox.fetch` (`Added from LibriVox: <name>`) have titles +of their own (`FINISHED_OWN` in `lib/server-events.ts`). A read-along reads `Read-along made for <name>`, with `N% of the text lined up. Open the book in a compatible AudioSilo player to follow the text with the narration.` under it. Every other kind, including `sidecars.contribute` and `meta.correct`, reads `Bridge job finished: <name>` -with the kind's name under it. +with the kind's name under it. A parked `librivox.fetch` words its code in the +bell as Library > LibriVox does (`fetchParkKey`). `bridge_job_failed` reads `A Bridge job needs attention: <title>` (in the warning colour) or `<kind> failed: <title>`. The kind's words come from `notify.bridgeKindWords`: `Characters and recaps`, `Description`, `Chapters`, -`Sharing with the community`, `Corrections`, `Read-along`, `Bridge check`, else -`Bridge job`. Its text is the park code in words: +`Sharing with the community`, `Corrections`, `Read-along`, `Adding from LibriVox`, +`Bridge check`, else `Bridge job`. Its text is the park code in words: - `book_not_writable`; - `budget_exceeded`: raise the cap on the Bridge, or wait for the monthly one diff --git a/docs-developers/server/data-model.md b/docs-developers/server/data-model.md index a29b1c9b..163049b8 100644 --- a/docs-developers/server/data-model.md +++ b/docs-developers/server/data-model.md @@ -1816,8 +1816,15 @@ or an apply took, with or without an ASIN or ISBN. Columns: `run_id` and `set_by` have no foreign keys, because runs are pruned and the row outlives either. -Only a community edit writes it, in the edit's transaction -(`catalog.BookEdit.Match`, a `catalog.MatchAnchor`). A bulk edit refuses one. +Two things write it: + +- a community edit, in the edit's transaction (`catalog.BookEdit.Match`, a + `catalog.MatchAnchor`). A bulk edit refuses one; +- the fold of a done `librivox.fetch` (`catalog.FoldLibrivoxFetch`), for the book + the Bridge placed: `how` `accepted`, `set_by` the admin who asked, no run and no + offered identifiers. It replaces a row already at that path and touches no + other (see [Add from LibriVox](../bridge/librivox.md#the-books-identity)). + An empty work id unlinks the book. That clears the one book's community values, cover and row, as [clearing the community matches](api/reference.md#delete-apiv1admincommunity-matches) diff --git a/docs-developers/server/scanner.md b/docs-developers/server/scanner.md index 6a26a88a..3520d498 100644 --- a/docs-developers/server/scanner.md +++ b/docs-developers/server/scanner.md @@ -1464,6 +1464,11 @@ A book's hidden **`.audiosilo/`** folder holds what the A single-file book's files carry its file stem first (`Book.characters.json`), in a `.audiosilo/` beside it. +Any other name there is skipped (`knownAttachment`). A book the Bridge +[added from LibriVox](../bridge/librivox.md#audiosilolibrivoxjson) holds +`librivox.json`, which the server does not read: the book is indexed with no +attachments from it and no scan error. + They are not a metadata layer. Nothing in the effective row reads them except `chapters.json`, which is a chapter source of its own, and `/meta` serves the rest. Recording a book's attachments (`SetAttachments`, `RefreshAttachments`) @@ -1472,7 +1477,8 @@ re-resolves the book when its `chapters.json` changed. The exact rule is in The scan finds them the way it finds an ABS `metadata.json`: -- **Discovery** never walks into a hidden folder. A folder named exactly +- **Discovery** never walks into a hidden folder, such as the Bridge's LibriVox + staging folder (`.audiosilo-librivox-<id>/`). A folder named exactly `.audiosilo` (a real folder, not ignored) is the exception: it is **listed once** without going in (`audioDirs`' `sidecarFiles`, beside the ABS signatures). Its known regular files are recorded by the folder holding it. diff --git a/docs-developers/server/web-ui.md b/docs-developers/server/web-ui.md index 2a25d481..94fd5293 100644 --- a/docs-developers/server/web-ui.md +++ b/docs-developers/server/web-ui.md @@ -684,7 +684,10 @@ The aside's Community card (`book-aside.tsx`) shows the book's - the work id, linking `web_url`, and the `recording_id` when set; - how and when it was matched (`howKey`): "No ASIN or ISBN: matched to its store listing ..." without an identifier, and "Matched by a bulk match ..." - or "Matched ..." with one; + or "Matched ..." with one. A match whose `/meta` recording carries + `librivox` (and is the match's `recording_id`, when it names one) reads + "Matched to its LibriVox recording ...". Until the book's `/meta` answers, the + line stays the neutral "Matched ..."; - **Unlink**, behind a `ConfirmDialog` (no undo). It sends `PATCH …/book {match: {work_id: ""}}` and refreshes the issues. @@ -809,6 +812,37 @@ transcript was reused". - `KIND_READALONG` counts in the page's job poll. The page fetches the book again when the job ends, so its new map shows. +#### Library > LibriVox + +Library > LibriVox (`features/librivox/`) adds a LibriVox recording to a books +library through the Bridge's `librivox.fetch`. The design is in +[Add from LibriVox](../bridge/librivox.md). + +- **Metadata off**: with the `metadata` capability off the page says so and links + Settings > Metadata, and asks nothing. +- **Search**: a search box over + [`GET /admin/librivox/search`](api/reference.md#get-apiv1adminlibrivoxsearch), + asked as the admin types. A result opens the work beside it. +- **The work**: [`GET /admin/librivox/work`](api/reference.md#get-apiv1adminlibrivoxwork) + lists the recordings, each with its readers, length, section count, project id + and a **LibriVox page** link. A recording in `in_library` shows **In library** + with a link to each book. +- **The Bridge note** (`bridgeNote` in `librivox-model.ts`, from + `GET /admin/bridge`): `addBlock` stops Add while the status loads or fails, with + no Bridge paired, or when its `features` lack `librivox`, each with a sentence. + With no Bridge paired, the sentence links Settings > Bridge. An offline Bridge + is no block: the note says a book added waits for it. +- **Add**: a library picker when there are several books libraries (podcast + libraries are never offered), then `POST /admin/librivox/fetch`. A refusal shows + as a toast (`errors.librivoxInLibrary` for `409 in_library`). +- **Following the job**: a row follows its job by id + (`GET /admin/bridge/jobs/{id}`) every few seconds while it is open + (`jobPollMs`), and stops for a job that is gone (`404`, `jobGone`). The work + itself is not polled. When the job ends it asks the work again and, for a done + job, once more after `DONE_RECHECK_MS` (2 s), since the fold commits just after + the job reads done. A parked or failed job keeps its reason on the row + (`fetchReason`). + #### Library > Libraries **Library cards** come from `GET /admin/libraries`, with `book_count`, @@ -1411,6 +1445,11 @@ A failed Audible job's code reads as a sentence (`audibleJobReason`): A sync cancelled by turning sync off shows the server's reason, "sync with Audible was turned off for the account" (`SERVER_CANCEL_REASONS`). +**LibriVox jobs.** A `librivox.fetch` reads "Adding from LibriVox", with its +project ("Project 192", `librivoxProject`). Its failure or park code reads as +`librivox.code.<code>` (`fetchReason`: a park's code, else the result's `code`, +else an error starting `<code>:`), and an unknown one keeps the Bridge's text. + ##### The Setup card The **Setup** card reads "Get set up" while a required step is still to do. It diff --git a/docs-users/admin/books.md b/docs-users/admin/books.md index 5c4888e6..f29405ed 100644 --- a/docs-users/admin/books.md +++ b/docs-users/admin/books.md @@ -84,7 +84,8 @@ The community database holds some books as a store listing with no ASIN or ISBN. A match to one of them still counts. The book shows as **Matched**, and [Match automatically](health.md#store-listings-with-no-asin-or-isbn) skips it. Its **Community metadata** card says "No ASIN or ISBN: matched to its store -listing". +listing". A book [added from LibriVox](librivox.md) says it is matched to its +LibriVox recording instead. A few things need an ASIN or ISBN, so they leave such a book out: the [community chapter check](#community-chapters) and the missing books on the diff --git a/docs-users/admin/bridge.md b/docs-users/admin/bridge.md index 376db9ad..4f0240a5 100644 --- a/docs-users/admin/bridge.md +++ b/docs-users/admin/bridge.md @@ -114,7 +114,8 @@ properly. The **Jobs** card lists what the Bridge was asked to do, newest first: characters and recaps, descriptions, chapters, read-alongs, community shares, corrections, -podcast downloads and Audible backups. **Show jobs** filters it by state. +podcast downloads, Audible backups and books added from LibriVox. **Show jobs** +filters it by state. Each job shows its book, its progress, who asked and when. Jobs the server queues itself say **Scheduled by the server**. When a job is done, it says what it made. diff --git a/docs-users/admin/console-tour.md b/docs-users/admin/console-tour.md index 905f14b7..d2b0165e 100644 --- a/docs-users/admin/console-tour.md +++ b/docs-users/admin/console-tour.md @@ -64,6 +64,7 @@ What each section is for: | **Library > Authors**, **Series**, **Narrators** | Merging two spellings of a name, and the books missing from a series. See [Authors and narrators](books.md#authors-and-narrators). | | **Library > Folders** | How AudioSilo reads each folder. See [The Folders page](libraries.md#the-folders-page). | | **Library > Libraries** | Add, edit, rescan, export and delete libraries. See [Add and manage libraries](libraries.md). | +| **Library > LibriVox** | Search LibriVox and have your Bridge add a free book to a library. See [Add free books from LibriVox](librivox.md). | | **People > People** and **Invites** | Accounts and invite links. See [Invite people](users-and-invites.md). | | **People > Shares** | Named sets of folders you give to people. See [Sharing](sharing.md). | | **People > Devices** | Every signed-in phone, browser and API key. See [The Devices page](users-and-invites.md#the-devices-page). | diff --git a/docs-users/admin/librivox.md b/docs-users/admin/librivox.md new file mode 100644 index 00000000..37e33846 --- /dev/null +++ b/docs-users/admin/librivox.md @@ -0,0 +1,129 @@ +--- +title: Add free books from LibriVox +description: "Find a free public-domain audiobook from LibriVox in the admin console or the player, and have your Bridge download it into a library, already matched to its recording." +--- + +An admin can search LibriVox's free public-domain audiobooks and add one to a +books library. Your [Bridge](bridge.md) downloads the recording, puts it in the +library and has the server index it. The book arrives matched to its LibriVox +recording, so its community details show with no matching by hand. + +You need: + +- an admin account; +- community metadata turned on, in **Server > Settings > Community metadata**; +- a paired Bridge, updated together with your server; +- the library shown as **Ready** in [Server > Settings > Bridge](bridge.md#libraries). + +The search reads the community metadata database, so your server never asks +LibriVox itself. The Bridge reads the recording's list of sections from the +community metadata database too, and downloads the audio from archive.org, where +LibriVox keeps its recordings. + +:::note[A new books library] +The Bridge checks a books library against a book the server has indexed. A +library with no books yet can't be **Ready**, so add one book to it first (see +[Libraries](bridge.md#libraries)). +::: + +## Add a book in the console + +To add a book, open **Library > LibriVox** in the admin console. + +![Library > LibriVox: a search and the recordings of the book found](/img/screenshots/admin/librivox.png) + +1. Type a title or an author in **Search LibriVox**. +2. Select a result. Its list of recordings shows who reads each one, its + length, how many sections it has and its LibriVox project number. +3. Pick the library, when you have more than one books library. +4. Press **Add**, or **Add to** *your library* when there is only one. + +The recording's row shows the Bridge's progress. When it is done, the row says +**In library** with a link to the book. + +**LibriVox page** on a row opens the recording on librivox.org. + +When the Bridge is offline, the page says so. A book you add then waits, and the +Bridge starts it when it is back. + +## Add a book in the player + +To add a book from the app or the web player, open the **Library** tab on a +books library. + +1. Press the **...** button beside the sort and layout controls. It shows only + while the Bridge is online. +2. Choose **Find on LibriVox**. +3. Type a title or an author, press **Search** and select a result. +4. Press **Add to** *the library* on the recording you want. + +![Find on LibriVox in the web player](/img/screenshots/web-player/find-librivox.png) + +The book goes to the library you opened the page from. With more than one books +library, you can pick another under **Add to**. + +The row shows the progress, with **Cancel** while it runs. Once the book is in, +the row says **In your library**, and **Open** opens its page. + +## What lands in your library + +The Bridge makes one folder for the book, by author and title. For example: + +``` +Charles Dickens/ + A Christmas Carol/ + 01 - Marley's Ghost.mp3 + 02 - The First of the Three Spirits.mp3 + ... + cover.jpg + .audiosilo/librivox.json +``` + +- Each section is one file and one chapter, numbered in reading order. +- When the author already has a folder in the library, the book goes in it. +- A book with more than three authors goes under **Various**. +- A book with several LibriVox recordings is named by the recording's own title, + such as "(version 2)", so two recordings never share a folder. +- If a folder of that name already exists, the Bridge uses + *Title* (LibriVox *number*) instead. It never writes into an existing book. +- `.audiosilo/librivox.json` records which LibriVox recording the folder holds. + +While it downloads, the Bridge keeps the files in a hidden folder beside the +book, which the server ignores. The book appears all at once when every section +is in. + +The Bridge adds one book at a time. Audible backups and the podcast episodes +the server fetches on its own take turns with it; an episode a listener is +waiting for does not wait. + +## Cancel or restart + +**Cancel** on the player's row stops the download. In the console, use +**Cancel** in the [Jobs](bridge.md#jobs) list on **Server > Settings > Bridge**. +A cancelled book leaves nothing behind in the library. + +If the Bridge stops part way, it picks up where it left off: sections it already +downloaded are kept, and a section cut off part way usually resumes. + +## When adding fails + +The row says why. Press the add button (**Add to** *your library*) again to try once more. + +| Reason | What to do | +|---|---| +| **The Bridge can't write to this library.** | Check the library is **Ready** in [Server > Settings > Bridge](bridge.md#libraries). A book that **Needs attention** for this carries on by itself once the library is ready. | +| **A book is already at that place in the library.** | Something is already in the folders the book would go in. Move or rename that folder. | +| **The library's disk is full.** | Free some space in the library. | +| **LibriVox no longer lists this project.** | The recording left the community database. Search again. | +| **archive.org couldn't be reached.** | Try again later. | +| **Community metadata was turned off.** | Turn it on in **Server > Settings > Community metadata**. If it is on, the Bridge has no metadata service set: check its `AUDIOSILO_BRIDGE_METADATA_BASE_URL`. | +| **That recording is already in a library.** | Nothing to do. The recording's row links to it. | + +## Good to know + +- The server tells you when a book is added or fails, in the bell and your + [notifications](notifications.md). +- [Clear community matches](server.md#community-metadata) removes the match of + books added from LibriVox too. To get it back, press **Add** on the recording + again. When the book is still in the folder the Bridge put it in, the Bridge + finds it there and downloads nothing, and the server matches it again. diff --git a/docs-users/faq.md b/docs-users/faq.md index 8fb0ba37..55d1fe41 100644 --- a/docs-users/faq.md +++ b/docs-users/faq.md @@ -67,6 +67,7 @@ Only what a lookup needs: - **Community metadata** sends meta.audiosilo.app a book's ASIN or ISBN, or the community entry an admin matched it to. Matching a book also sends details like its title and author (see [Server settings](./admin/server.md#community-metadata)). The server never sends listener details or full file paths. A match can send up to three names from the book's path (its author, series and book folders) as search terms. - **Help complete the community database** (off by default) sends, once a day, the ASINs the database doesn't know. See [Helping complete the community database](./admin/server.md#helping-complete-the-community-database). +- **Add from LibriVox**, when an admin uses it, sends the search text to meta.audiosilo.app. The Bridge then reads the recording's list of sections from meta.audiosilo.app (or the metadata service it is set to use) and downloads the book from archive.org. See [Add free books from LibriVox](./admin/librivox.md). - **Podcast search** sends the search text to Apple Podcasts or Podcast Index. See [Searching podcast directories](./admin/podcasts.md#searching-podcast-directories). - **The update check** asks GitHub once a day for the newest version. See [What the update check sends](./admin/server.md#what-the-update-check-sends). diff --git a/docs-users/features.md b/docs-users/features.md index 3d4be4a1..dc2a0cd8 100644 --- a/docs-users/features.md +++ b/docs-users/features.md @@ -63,6 +63,7 @@ Everything else works with the server alone. - [Community metadata](community/meta-site.md): a free database of books, series and release dates. - [Characters, recaps and chapters](admin/generating-characters-and-recaps.md) for books the community hasn't covered. **(Bridge)** - [Back up your Audible books](admin/audible.md) and keep your places in step with Audible. **(Bridge)** +- [Add free books from LibriVox](admin/librivox.md): search LibriVox and download a public-domain audiobook into a library, already matched. **(Bridge)** - [Contribute from your server](community/contributing-from-your-server.md): share corrections and recaps with the community. **(Bridge)** - [Import from Audiobookshelf](admin/import.md): bring over listening history, progress and bookmarks. diff --git a/docs-users/getting-started/running-a-bridge.md b/docs-users/getting-started/running-a-bridge.md index 73d0f571..06731e14 100644 --- a/docs-users/getting-started/running-a-bridge.md +++ b/docs-users/getting-started/running-a-bridge.md @@ -19,6 +19,8 @@ You don't need a Bridge to listen to your books. Add one for: narration in the book's ebook; - [Audible backups](../admin/audible.md) of the books you bought, and listening places kept in step with Audible; +- [free books from LibriVox](../admin/librivox.md), downloaded into a library + and matched to their recording; - [sharing with the community](../community/contributing-from-your-server.md). <a id="which-bridge-version"></a> @@ -215,7 +217,7 @@ To make it faster, or to give a `:latest-slim` Bridge a way to transcribe: ## 7. The agent's credential (optional) {/* #7-the-agents-credential */} Skip this step if you don't want characters, recaps or descriptions. Chapters, -read-alongs, podcasts and Audible work without the agent. +read-alongs, podcasts, Audible and LibriVox work without the agent. The agent needs a credential for an AI account. It runs **Claude Code** unless you switch it to Codex (below). The easiest place to set the credential is the diff --git a/docs-users/listening/browsing.md b/docs-users/listening/browsing.md index 6b32dd05..9caea119 100644 --- a/docs-users/listening/browsing.md +++ b/docs-users/listening/browsing.md @@ -82,6 +82,7 @@ Every book in the library, as a cover grid or a list: - **Genre** and **Tag**, when the library's books have them. Tapping a genre or tag on a [book's page](book-page.md#about-this-book) opens this view filtered to it. - **Sort** by **Recently added**, **Title**, **Author** or **Length**. Sorted by title, an **A to Z** rail jumps to a letter. - **Grid or list**. On a tablet or computer, the list adds columns for narrator, length and progress. +- **...** beside them, for an admin: **Find on LibriVox** adds a free book to the library (see [Add free books from LibriVox](../admin/librivox.md)). ![The Genre filter open over the Library's books](/img/screenshots/web-player/library-genre.png) diff --git a/screenshots/README.md b/screenshots/README.md index 5a7c3666..971e93b3 100644 --- a/screenshots/README.md +++ b/screenshots/README.md @@ -266,6 +266,17 @@ folder under the Inbox (`.cache/inbox`, rebuilt every run), asks for the attachm rescan and reports the job done. The book's Chapters card is then shot clipped, with the found chapters in use. Nothing is written into the seeded library. +The LibriVox shots (`admin/librivox.png`, `web-player/find-librivox.png`) come +after the read-along step, from the same stand-in, its heartbeat sent again with +`librivox.fetch` among its kinds so the Bridge's features list `librivox`. Both +search the live community metadata (the demo server's default lookup, so they need +the network) for The Wind in the Willows and open its recordings: first the +console's Library > LibriVox, then the web player in a context of its own, signed +in as the admin (Browse the library, so nothing plays) and opened from the Books mode's **...** menu (Find on LibriVox), +which shows only while the Bridge is online. Both are taken by `capture-admin.mjs`, +since that is where the stand-in is paired. Nothing is added, so no job is queued +and no seeded book gains a match; the player's session is signed out again. + The generated description shots (`admin/book-description.png`, `web-player/book-about-generated.png`, Bridge Phase 1c) need a book with a `description.json`. Before the servers start, on every run, `run.sh` writes the diff --git a/screenshots/capture-admin.mjs b/screenshots/capture-admin.mjs index d06610f8..3e24780b 100644 --- a/screenshots/capture-admin.mjs +++ b/screenshots/capture-admin.mjs @@ -45,16 +45,21 @@ import { addHousehold, apiClient, around, + drawerClosed, fitViewportToPage, isoAgo, pathQuery, + playerSession, + probeServer, showDockerPaths, silence, sleep, shoot, + signInWithPassword, stamp, step, submitSignIn, + tid, waitForScans, wanted, DESKTOP_CONTEXT, @@ -1126,13 +1131,14 @@ const STORY_SHOTS = ['admin/book-story.png', 'admin/book-story-recaps.png']; const DETECT_SHOTS = ['admin/health-detect-chapters.png', 'admin/detect-chapters-dialog.png', 'admin/book-chapters-generated.png']; const READALONG_SHOTS = ['admin/readalong-dialog.png', 'admin/book-readalong.png']; +const LIBRIVOX_SHOTS = ['admin/librivox.png', 'web-player/find-librivox.png']; // The stand-in's heartbeat. The server keeps a heartbeat it just saved as the // current one, so the next GET /server sees it at once. const standInHeartbeat = (status) => api(bridgeToken, 'PUT', '/bridge/status', status, BRIDGE_CLIENT); await step('settings: bridge', async () => { if (!wanted('admin/settings-bridge.png', 'admin/bridge-pair.png', 'admin/bridge-setup.png', 'admin/settings-bridge-paired.png', ...GENERATE_SHOTS, ...STORY_SHOTS, ...DETECT_SHOTS, - ...READALONG_SHOTS)) return; + ...READALONG_SHOTS, ...LIBRIVOX_SHOTS)) return; const info = await api(null, 'GET', '/server'); if (!info?.capabilities || !('bridge' in info.capabilities)) throw new Error('this server build has no Bridge'); try { @@ -1536,6 +1542,72 @@ await step('bridge: make a read-along', async () => { } }); +// ── Add from LibriVox ──────────────────────────────────────────────────────── +// With the stand-in Bridge offering librivox.fetch (so the Bridge's features list +// librivox), Library > LibriVox searches the live community metadata (the demo +// server's default lookup, so it needs the network) for a work with many LibriVox +// recordings and opens it beside the results. Then the web player, signed in as +// the admin in a context of its own, opens the same search from the Books mode's +// ... menu (Find on LibriVox), which shows only while the Bridge is online, for an +// admin, with metadata on. Nothing is added: no job is queued, so no book gains a +// match. The player's session is signed out again. +const LIBRIVOX_QUERY = 'The Wind in the Willows'; +await step('bridge: add from LibriVox', async () => { + if (!wanted(...LIBRIVOX_SHOTS)) return; + if (!bridgeToken || !bridgeStatus) throw new Error('no stand-in Bridge was paired'); + const status = {...bridgeStatus, kinds: [...bridgeStatus.kinds, 'librivox.fetch']}; + await standInHeartbeat(status); + const info = await api(token, 'GET', '/server'); + if (!info?.bridge?.features?.includes('librivox')) throw new Error('this server build has no librivox.fetch'); + + if (wanted('admin/librivox.png')) { + await open(page, '/library/librivox'); + await page.getByLabel('Title or author').fill(LIBRIVOX_QUERY); + const hit = page.getByRole('list', {name: 'Search results'}) + .getByRole('button', {name: new RegExp(`^${LIBRIVOX_QUERY}`)}).first(); + await hit.waitFor({timeout: 30000}); + await hit.click(); + const recordings = page.getByRole('list', {name: 'Recordings'}); + await recordings.getByText(/^Read by /).first().waitFor({timeout: 30000}); + await recordings.getByRole('button', {name: /^Add/}).first().waitFor({timeout: 15000}); + await sleep(800); + await shoot(page, 'admin/librivox.png'); + } + + if (wanted('web-player/find-librivox.png')) { + const wctx = await browser.newContext(DESKTOP_CONTEXT); + await wctx.addInitScript(drawerClosed); + let session = null; + try { + const wpage = await wctx.newPage(); + const visible = (id) => tid(wpage, id); + await wpage.goto(`${ORIGIN}/web/connect`, {waitUntil: 'networkidle', timeout: 45000}); + await probeServer(wpage, ORIGIN); + await signInWithPassword(wpage, 'admin', PASSWORD); + await visible('ready-screen').waitFor({timeout: 15000}); + session = (await playerSession(wpage)).token; + // Browse the library, not Start listening: nothing plays, so no session is recorded. + await wpage.getByRole('button', {name: 'Browse the library'}).filter({visible: true}).first().click(); + await visible('top-bar-(library)').waitFor({timeout: 15000}); + // Again, so a slow console shot cannot leave the Bridge offline: the player's + // /server names the Bridge's features only while it is online. + await standInHeartbeat(status); + await visible('books-menu').click({timeout: 20000}); + await visible('books-find-librivox').click({timeout: 8000}); + await visible('librivox-search-input').fill(LIBRIVOX_QUERY); + await visible('librivox-search-submit').click(); + await visible('librivox-hit').click({timeout: 30000}); + await visible('librivox-recording').waitFor({timeout: 30000}); + await visible('librivox-add').waitFor({timeout: 15000}); + await sleep(800); + await shoot(wpage, 'web-player/find-librivox.png'); + } finally { + if (session) await api(session, 'POST', '/auth/logout').catch(() => {}); + await wctx.close(); + } + } +}); + await step('webhook deliveries', async () => { // What the local receiver got: every signature must check out. if (!hookBodies.length) throw new Error('the webhook received nothing'); diff --git a/screenshots/capture-web.mjs b/screenshots/capture-web.mjs index 95375eda..d5384e65 100644 --- a/screenshots/capture-web.mjs +++ b/screenshots/capture-web.mjs @@ -40,13 +40,17 @@ import { addHousehold, apiClient, around, + drawerClosed, firstVisible, fitViewportTo, fitted, imagesLoaded, isoAgo, pathQuery, + playerSession, + probeServer, shoot, + signInWithPassword, sleep, slug, stamp, @@ -90,16 +94,6 @@ const COLLECTION = { books: ['Christmas Carol', 'Hound of the Baskervilles', 'Adventures of Sherlock Holmes', 'Looking-Glass'], }; -// The desktop Up next drawer starts closed in every capture context, so the shots -// that aren't about it keep the page's full width; the Up next shot opens it. -const drawerClosed = () => { - try { - localStorage.setItem('audiosilo.upNext', JSON.stringify({drawerOpen: false})); - } catch { - /* storage blocked: the drawer opens, the shots are just narrower */ - } -}; - // A capture context: DESKTOP_CONTEXT (1440x900, retina, dark, motion reduced) with // `options` on top (a viewport, stored auth, a time zone...), the Up next drawer // closed. `on` is the browser to open it in (the Cast shot has its own). @@ -169,18 +163,6 @@ const PRIMARY_PLAY = /^(start listening|resume( chapter \d+)?|listen again|liste // a bare web export, so it isn't read from there). const FELL_ASLEEP_NOTE = 'Fell asleep'; -// The signed-in player's connection id and token (the web player keeps the token in -// localStorage under audiosilo.token.<connection id>), for provisioning through the API. -async function playerSession(page) { - const session = await page.evaluate(() => { - const prefix = 'audiosilo.token.'; - const key = Object.keys(localStorage).find((k) => k.startsWith(prefix)); - return key ? {cid: key.slice(prefix.length), token: localStorage.getItem(key)} : null; - }); - if (!session?.token) throw new Error('no player token in localStorage'); - return session; -} - // The demo user's token. async function demoToken(page) { return (await playerSession(page)).token; @@ -1755,29 +1737,6 @@ if (wanted(...Object.values(PODCAST_SHOTS), ...Object.values(FIND_SHOTS))) { } } -// The connect start screen, done once the probe card shows what it found. A served -// player that asks its own server on open has no address field; one that has the field -// gets the server's address typed and Continue pressed. -async function probeServer(page, origin) { - await tid(page, 'connect-start').waitFor({timeout: 15000}); - const field = firstVisible(page.getByRole('textbox', {name: 'Server address'})); - if (await field.count()) { - await field.fill(origin); - await firstVisible(page.getByRole('button', {name: 'Continue', exact: true})).click({timeout: 8000}); - } - await tid(page, 'probe-notice').waitFor({timeout: 15000}); -} - -// From the probe card: Sign in, by username and password. -async function signInWithPassword(page, username, password) { - await firstVisible(page.getByRole('button', {name: /^Sign in to /})).click({timeout: 8000}); - await tid(page, 'sign-in-step').waitFor({timeout: 8000}); - await firstVisible(page.getByRole('radio', {name: 'Username and password'})).click({timeout: 8000}); - await firstVisible(page.getByLabel('Username', {exact: true})).fill(username); - await firstVisible(page.getByLabel('Password', {exact: true})).fill(password); - await firstVisible(page.getByRole('button', {name: 'Sign in', exact: true})).click({timeout: 8000}); -} - // ── Household: profiles, members, kids mode, listening in the house, Cast ─── // These shots need people, not the demo: a demo account can't share what it listens to // or add household members, and profiles, PINs and kids mode live in this browser's diff --git a/screenshots/lib.mjs b/screenshots/lib.mjs index 85a4f82f..4ccee0b2 100644 --- a/screenshots/lib.mjs +++ b/screenshots/lib.mjs @@ -38,6 +38,51 @@ export const pathQuery = (relPath) => `?path=${encodeURIComponent(relPath)}`; export const firstVisible = (locator) => locator.filter({visible: true}).first(); export const tid = (page, id) => firstVisible(page.getByTestId(id)); +// The desktop Up next drawer starts closed in every capture context, so the shots +// that aren't about it keep the page's full width; the Up next shot opens it. +export const drawerClosed = () => { + try { + localStorage.setItem('audiosilo.upNext', JSON.stringify({drawerOpen: false})); + } catch { + /* storage blocked: the drawer opens, the shots are just narrower */ + } +}; + +// The signed-in player's connection id and token (the web player keeps the token in +// localStorage under audiosilo.token.<connection id>), for provisioning through the API. +export async function playerSession(page) { + const session = await page.evaluate(() => { + const prefix = 'audiosilo.token.'; + const key = Object.keys(localStorage).find((k) => k.startsWith(prefix)); + return key ? {cid: key.slice(prefix.length), token: localStorage.getItem(key)} : null; + }); + if (!session?.token) throw new Error('no player token in localStorage'); + return session; +} + +// The connect start screen, done once the probe card shows what it found. A served +// player that asks its own server on open has no address field; one that has the field +// gets the server's address typed and Continue pressed. +export async function probeServer(page, origin) { + await tid(page, 'connect-start').waitFor({timeout: 15000}); + const field = firstVisible(page.getByRole('textbox', {name: 'Server address'})); + if (await field.count()) { + await field.fill(origin); + await firstVisible(page.getByRole('button', {name: 'Continue', exact: true})).click({timeout: 8000}); + } + await tid(page, 'probe-notice').waitFor({timeout: 15000}); +} + +// From the probe card: Sign in, by username and password. +export async function signInWithPassword(page, username, password) { + await firstVisible(page.getByRole('button', {name: /^Sign in to /})).click({timeout: 8000}); + await tid(page, 'sign-in-step').waitFor({timeout: 8000}); + await firstVisible(page.getByRole('radio', {name: 'Username and password'})).click({timeout: 8000}); + await firstVisible(page.getByLabel('Username', {exact: true})).fill(username); + await firstVisible(page.getByLabel('Password', {exact: true})).fill(password); + await firstVisible(page.getByRole('button', {name: 'Sign in', exact: true})).click({timeout: 8000}); +} + // The standard desktop capture context (retina, dark, motion-reduced) shared by // the capture scripts. capture-web spreads it into its capture contexts // (`newCaptureContext`), overriding the viewport where a shot needs it; its warm-up and diff --git a/screenshots/manifest.mjs b/screenshots/manifest.mjs index b78275cc..80441894 100644 --- a/screenshots/manifest.mjs +++ b/screenshots/manifest.mjs @@ -160,6 +160,8 @@ export const SHOTS = [ {file: 'admin/book-chapters-generated.png', capture: 'admin', title: 'Admin - Chapters found in the narration', hint: "A Long Lecture's Chapters card (clipped) once the stand-in Bridge finished its chapters.detect job: the chapters.json it wrote (as a Bridge does) in use, Found in the narration on this server. and Use the file's chapters over the timeline and the list"}, {file: 'admin/readalong-dialog.png', capture: 'admin', title: 'Admin - Make a read-along dialog', hint: "the Make a read-along confirmation over A Christmas Carol's page, while the stand-in Bridge offers readalong.align: what it does, the Ebook, Transcription (up to, from the stand-in's figures), Agent cost None: no agent is used, and the note on a poor match; then submitted, and the stand-in runs the job"}, {file: 'admin/book-readalong.png', capture: 'admin', title: 'Admin - Read-along card', hint: "A Christmas Carol's Read-along card (clipped) once the stand-in Bridge finished its readalong.align job: the readalong.json it wrote (its head: the EPUB's sha256, the book's files and lengths, 99.6% text coverage, made two days ago) Ready, the job's 99% of the text lined up and the transcript was reused, and Make again"}, + {file: 'admin/librivox.png', capture: 'admin', title: 'Admin - Library > LibriVox', hint: "Library > LibriVox while the stand-in Bridge offers librivox.fetch (online, so no Bridge note): Search LibriVox with The Wind in the Willows typed, the one result (Kenneth Grahame) selected, and its Recordings beside it, each with its readers, length, sections, project number, LibriVox page and the library picker with Add (live community metadata; nothing is added)"}, + {file: 'web-player/find-librivox.png', capture: 'admin', title: 'Web player - Find on LibriVox', hint: "the web player signed in as the admin (Browse the library, so nothing plays), opened from the Books mode's ... menu (Find on LibriVox) while the stand-in Bridge offers librivox.fetch: the intro, the Add to picker, The Wind in the Willows searched, its result opened with the first recordings, each with Read by, the sections, Project N, LibriVox page and Add to <library> (live community metadata; captured by capture-admin.mjs, which pairs the stand-in; nothing is added)"}, {file: 'admin/book-description.png', capture: 'admin', title: 'Admin - Generated description', hint: "The Call of the Wild's Community metadata card (clipped) with its Description part: the description run.sh seeds into its .audiosilo folder, Generated on this server two days ago, the text clamped behind Show more; shot before the stand-in Bridge is paired, so there is no Characters and recaps part"}, {file: 'admin/book-community-match.png', capture: 'admin', title: 'Admin - Recorded community match', hint: "The Hound of the Baskervilles' Community metadata card (clipped), matched for the shot through the match endpoint's first candidate with an ASIN (live meta.audiosilo.app): ASIN, Community work, Recording, Matched just now, Compare fields and Unlink; unlinked again afterwards, and shot before the stand-in Bridge is paired"}, {file: 'admin/bell.png', capture: 'admin', title: 'Admin - Notifications bell', hint: 'the top bar bell open over the overview: new books and sign-ins'}, diff --git a/sidebars-developers.ts b/sidebars-developers.ts index 0a901eb8..30a596af 100644 --- a/sidebars-developers.ts +++ b/sidebars-developers.ts @@ -87,6 +87,7 @@ const sidebars: SidebarsConfig = { 'bridge/contributions', 'bridge/podcasts', 'bridge/audible', + 'bridge/librivox', 'bridge/workers', 'bridge/configuration', 'bridge/development', diff --git a/sidebars-users.ts b/sidebars-users.ts index 18fed29c..eecfa79d 100644 --- a/sidebars-users.ts +++ b/sidebars-users.ts @@ -104,6 +104,7 @@ const sidebars: SidebarsConfig = { 'admin/generating-characters-and-recaps', 'admin/making-read-along', 'admin/audible', + 'admin/librivox', 'getting-started/running-a-worker', ], }, diff --git a/static/img/screenshots/admin/librivox.png b/static/img/screenshots/admin/librivox.png new file mode 100644 index 00000000..907f8b76 Binary files /dev/null and b/static/img/screenshots/admin/librivox.png differ diff --git a/static/img/screenshots/web-player/find-librivox.png b/static/img/screenshots/web-player/find-librivox.png new file mode 100644 index 00000000..b653ffe5 Binary files /dev/null and b/static/img/screenshots/web-player/find-librivox.png differ