diff --git a/docs-developers/architecture/cross-repo-contract.md b/docs-developers/architecture/cross-repo-contract.md index ed9f785b..b5a0006b 100644 --- a/docs-developers/architecture/cross-repo-contract.md +++ b/docs-developers/architecture/cross-repo-contract.md @@ -485,13 +485,16 @@ and two URL carriers. **Server:** - `internal/api/qr.go` `buildPairing` emits two links: - - `web_url` (`/web/connect?token=…`) is encoded in the QR. It opens the app - through Universal Links or App Links on claimed domains, and otherwise the web - player. + - `web_url` (`/web/connect?token=…`) is encoded in the QR. The app's own + scanner reads it. A phone's camera app opens it at the web player, since the + store apps claim no domain (only a build named in `app_links` does). - `uri` (`audiosilo://connect?server=&token=`) uses the custom scheme. - The flow is `POST /auth/redeem` (a code for a pairing payload), then `POST /auth/exchange` (a pairing token for a device-scoped session). +- The redeem payload adds two optional fields for the connect page: `admin` + (true when the code pairs an admin's account) and `links.apk` beside + `links.ios` and `links.android` (the store links). The player reads neither. **Frontend:** @@ -1610,14 +1613,17 @@ not quiet, deduped per library and project, and in no module. `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 server builds its params, `{librivox_id, work_id, recording_id, library_id, + title}`, with the work, recording and title from metaserve's + `lookup?librivox_id=` at queue time. `title` is optional and the Bridge ignores + it. The Bridge reads the params 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. + `{library_id, path, librivox_id, files, bytes, adopted}`, where `path` is the + book path that `POST /bridge/rescan` answered. `adopted` is `false` only when + this run placed the folder. The Bridge always sends it, and the server reads it + as optional for an older Bridge. - A failure is `{code, message}`, with `not_writable`, `exists`, `disk_full`, `not_found`, `archive_unavailable` or `metadata_off`. - It uses no new Bridge route. @@ -1625,7 +1631,10 @@ not quiet, deduped per library and project, and in no module. (`{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)). + ([Matches by work id](#matches-by-work-id)), by `adopted`: a placed book + replaces the path's match and drops its leftover ASIN and ISBN, an adopted one + keeps a match already there. The `bridge_job_finished` event carries + `librivox_match` (`matched`, `kept`, `unmatched`). See [Add from LibriVox](../bridge/librivox.md). diff --git a/docs-developers/bridge/librivox.md b/docs-developers/bridge/librivox.md index 88b8177c..6934f2d8 100644 --- a/docs-developers/bridge/librivox.md +++ b/docs-developers/bridge/librivox.md @@ -1,6 +1,6 @@ --- 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." +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, resume and the 7-day sweep, and .audiosilo/librivox.json." --- An admin searches the community metadata database's LibriVox works, picks a @@ -60,8 +60,8 @@ has the shapes and every status. | 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 | +| Notifications | not quiet. Done is "Added from LibriVox", named by the book its result names, and says how the book is matched ([The notification](#the-notification)). Failed, and a lasting park, are notified as for other kinds, named by the params' `title` when no book was placed | +| Bridge lane | `download` (1 slot, shared with `audible.import` and an ordinary `podcast.fetch`), so one book downloads at a time. A parked fetch gives the slot back while it waits ([Parked fetches](#parked-fetches)) | | Bridge state | `/librivox/-.json` | ### Params @@ -70,11 +70,18 @@ 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 } +{ "librivox_id": 192, "work_id": "johnny-crows-garden", "recording_id": "…", "library_id": 3, "title": "Johnny Crow's Garden" } ``` `work_id` and `recording_id` are what the server resolved at queue time -([The book's identity](#the-books-identity)). Neither may be blank. +([The book's identity](#the-books-identity)). Both must have the community's id +shape (`metaID`: a letter or digit, then up to 199 letters, digits, `.`, `_` or +`-`), the same rule the Bridge checks, so a bad id is refused at queue time. + +`title` is the work's title from the same lookup, cut to one line of at most 300 +bytes (`bridge.CleanText`). It is optional and additive: the server names a +fetch that placed no book by it, in its notification and in the console's job +list. The Bridge ignores it. ### Progress @@ -94,15 +101,32 @@ already downloading, **parks** instead (`needs_attention`, park code `not_writable`), and it carries on once a mapping check reports the library writable again. +#### Parked fetches + +A parked fetch gives its `download` lane slot back while it waits +(`jobs.Reporter.Yield`), so an Audible import or a podcast download can run in the +meantime. A yielded run counts as neither running nor waiting. Once the mapping +is back it takes a slot again (`Reclaim`, first in line), then checks the mapping +once more before it writes anything, since the wait for the slot can be long. +Downloads stay one at a time, and a fetch's sections stay serial. + ### Result ```json -{ "library_id": 3, "path": "L. Leslie Brooke/Johnny Crow's Garden", "librivox_id": 192, "files": 9, "bytes": 53477376 } +{ "library_id": 3, "path": "L. Leslie Brooke/Johnny Crow's Garden", "librivox_id": 192, "files": 9, "bytes": 53477376, "adopted": false } ``` `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. +`adopted` is `false` only when this run itself renamed the staging folder into +place, so the folder was vacant until now. Any run that finds the book already +placed reports `true`: a retry after a failed rescan, a placement the state +never recorded, or a folder that already holds this project's +`librivox.json`. The Bridge always sends it. The server reads it as optional, so +a result from an older Bridge without it still folds +([The book's identity](#the-books-identity)). + ### Failure codes A failed job's result is `{code, message}`, and its error is the message. The @@ -114,6 +138,7 @@ message never holds a URL. | `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 | +| (no code) | the metadata service stayed down, or refused the read with a permanent `4xx` ([Fetching from archive.org only](#fetching-from-archiveorg-only)) | | `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`) | @@ -138,9 +163,29 @@ The server, not the Bridge, decides which recording a fetched book is: 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. +`ErrFetchNotIndexed`. It never touches another path's rows. What it does at the +book's path depends on the result's `adopted`: + +| `adopted` | The folder | The fold | +|---|---|---| +| `false` | was vacant until this run placed it | replaces the path's `book_matches` row, and drops the path's ASIN and ISBN `book_overrides` (whoever set them) and its `book_enrichment` row, then rebuilds the book's effective metadata. Those rows are a deleted book's leftovers, and `/meta` would ask them before the match. | +| `true` | is an earlier fetch's book | inserts the match only when the path has none, so an admin's rematch stays, and touches nothing else | +| absent (an older Bridge) | not known | replaces the match and touches nothing else | + +#### The notification + +After the fold, the server reads how the book is matched now +(`catalog.LibrivoxFetchMatch`) and puts it in the `bridge_job_finished` event's +`librivox_match`: + +- `matched`: matched to the job's recording; +- `kept`: an adopted book already matched to something else, which the fold kept; +- `unmatched`: neither, or a result the fold refused (another library or + project than the job's, or no book indexed at its path). + +The message claims the recording only when it is `matched`, and an `unmatched` +one goes out as a warning. The wording is in +[Bridge messages](../server/backups-and-notifications.md#bridge-messages). "Where is this recording" is read from those stored rows alone (`catalog.WorkRecordingBooks`): indexed books whose `book_matches` row names that @@ -154,9 +199,11 @@ Two things drop the identity, because the server does not read - 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. +Adding the recording again repairs either while the path holds no match. 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 reports +`adopted: true`, and the fold records the match. A match recorded at the path +since then, such as a bulk run's, stays, and the notification says `kept`. ## Fetching from archive.org only @@ -173,7 +220,9 @@ The Bridge reads the manifest from **its own** metadata service 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`. +ends it as `not_found`. Any other permanent `4xx` (all but `404`, `408`, `425` and +`429`: `metaops.ErrRefused`) is a refusal, not an outage. The run reads once, +fails at once with no code, and removes its staging folder and state. 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 @@ -188,12 +237,15 @@ 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; + ID3 or APE tag, MPEG frame sync, RIFF `WAVE`, `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`. +nothing to a section fail the job `archive_unavailable`, and the run keeps its +staging folder and state as it does through a metadata outage, so the next +attempt resumes. A `4xx` other than `408`, `416` and `429` also fails it +`archive_unavailable`, and removes them. ## Placement and names @@ -246,8 +298,8 @@ the mount's `os.Root`: 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: +The state file records the library, the paired server's id, 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; @@ -257,10 +309,36 @@ once placed, the count and bytes. So: 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. + folder, an author folder it made that is empty, and the state. When the staging + folder can't be removed now (the mount is away, or the library isn't listed + yet), the state stays, so the sweep or a re-add finds it; +- a state of another server is dropped when it is read, so a run plans afresh, + and Abandon and the sweep never act on another server's same-numbered library. + A state with no server id counts as this server's. + +A second run of the same library and project, claimed while the first is still +ending (a re-add right after a cancel of a parked run), waits up to about two +minutes for it, then fails. Abandon leaves a running fetch alone. + +### The 7-day sweep + +Like `audible.import`'s, a sweep (`librivoxjob`'s `Handler.Sweep`) clears states unused for 7 +days (`StagingMaxAge`). A run touches its state when it loads it and when it ends. +The app runs the sweep after each mapping check, not at start-up, because the +staging folders are inside the libraries, which are known only after a heartbeat +and the check. The sweep: + +- skips a fetch that is running here, and one whose job row the runner still + holds (`store.OpenServerJobs`), such as a recovered job waiting for its lane. If + it can't read those rows, it sweeps nothing; +- keeps a state whose library isn't a checked writable mapping now, or whose + staging folder can't be removed; +- otherwise removes the state, its staging folder and the author folder it made. + For a placed book or another server's state it drops only the state file; +- removes other old regular files in the state folder, never a folder. + +A staging folder whose state is gone is out of its reach: it stays, hidden and +unindexed. ## `.audiosilo/librivox.json` diff --git a/docs-developers/bridge/pairing-and-jobs.md b/docs-developers/bridge/pairing-and-jobs.md index d9e28cbb..3fc02add 100644 --- a/docs-developers/bridge/pairing-and-jobs.md +++ b/docs-developers/bridge/pairing-and-jobs.md @@ -687,14 +687,17 @@ module: - 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; +- params `{librivox_id, work_id, recording_id, library_id, title}`, built by the + server (`title` optional); - 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. + or parked one is named by the params' `title`, 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 +ordinary `podcast.fetch` slot, and its partial download survives a restart. While +it is parked it gives its slot back (`jobs.Reporter.Yield`) and takes one again +before it carries on (see [Parked fetches](librivox.md#parked-fetches)). When it ends done, the server records the book as the recording. See [Add from LibriVox](librivox.md). diff --git a/docs-developers/frontend/connect-and-addresses.md b/docs-developers/frontend/connect-and-addresses.md index 77cb1702..1bd12b32 100644 --- a/docs-developers/frontend/connect-and-addresses.md +++ b/docs-developers/frontend/connect-and-addresses.md @@ -22,8 +22,16 @@ counted by `StepDots` (`CONNECT_STEPS`): Start, Sign in and Ready. ### Step 1: Start -`/connect` renders `ConnectStart`. Its address field asks the server who it is -(`GET /server` through a bare `ApiClient`) and shows a `ProbeNotice` with one of these: +`/connect` renders `ConnectStart`. In the app, **Scan a QR code** comes first: the +screen's one primary button, full width and `lg`. The lead +(`onboarding.start.lead`) says where the QR is: the console's People > your name > +Pair a device, or an invite page. The address field follows under "Or enter the +server address" (`connect.server.orAddress`), with a `secondary` Continue. The web +player has no scanner, so its address field comes first, with **I have a pairing +link**. The frontend's `STYLEGUIDE.md` has the layout. + +The address field asks the server who it is (`GET /server` through a bare +`ApiClient`) and shows a `ProbeNotice` with one of these: - "Found <name>" with its version, **Sign in**, and **Try the demo** when `demo.enabled`. @@ -50,9 +58,20 @@ set when the flow is left, and a sign-in stores it with the connection (see #### Pairing links, QR codes and invite links -Native offers **Scan a QR code** (`/connect/scan`, `expo-camera`). Web offers **I have a +Native offers **Scan a QR code** (`/connect/scan`, `expo-camera`), on the start step +and as "No password? Scan a QR code instead" on the sign-in step. Every way into the +scanner goes through `openScanner` (`invite-handoff.ts`). Web offers **I have a pairing link**, which takes a pasted link and parses it with `parsePairingScan`. +A scan from a reconnect's sign-in step keeps the reconnect. `openScanner(reconnectId)` +hands it over in memory, never as a route param, since a link can open that route +too. The scanner keeps it only for a code of that connection's paired or away +address (`scanReconnect`); any other address is a new connection. An invite code's +sign-in step takes it as its own `reconnect`. A pairing code's `/connect` takes it +by the code's token (`holdScanReconnect`, `takeScanReconnect`), checks the address +again as it pairs, and `useLinkPairing` re-pairs that connection. The flow's layout +drops what nothing took when the flow is left (`dropScanHandoffs`). + Both also take the console's **invite link**, `https:///connect#code=XXXX-XXXX-XXXX-XXXX`. `parseInviteScan` in `src/lib/pairing.ts` reads it, and the server builds it in `inviteURL`. A `/web/connect` diff --git a/docs-developers/frontend/library-and-browse.md b/docs-developers/frontend/library-and-browse.md index 6bbc0e25..bfda73c7 100644 --- a/docs-developers/frontend/library-and-browse.md +++ b/docs-developers/frontend/library-and-browse.md @@ -130,6 +130,20 @@ offers nothing while the Bridge is offline (see a sentence (`notAdmin`, `unavailable`, `metadataOff`): the search and its hooks never mount. +Once the page was `allowed` for a connection, it stays when a later `/server` read +finds the Bridge away (`unavailable`: its heartbeat lapsed, so `/server` dropped +its features). The search and the followed fetches are kept, a caption says so +(`librivox.access.away`), and Add and Cancel are held back (`bridgeAway`) until it +is back. While away, the page asks `/server` again every `BRIDGE_IDLE_POLL_MS` +with the app in the foreground, since nothing else reads it on an open page. +`notAdmin` and `metadataOff` still close the page. A `/server` read that failed +with nothing kept shows `librivox.access.readFailed` with Retry. + +A link without `?connection=` is the default connection's (`useCid`, as Find +shows). A link whose `library` the server doesn't have shows +`library.detail.badLink`, once the library list has settled (not while it loads +or refetches). + **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 @@ -142,7 +156,7 @@ scroll memory. | Method | Route | |---|---| -| `librivoxSearch(q, {lang, limit})` | `GET /admin/librivox/search` | +| `librivoxSearch(q)` | `GET /admin/librivox/search` (the server's `lang` and `limit` are never sent) | | `librivoxWork(id)` | `GET /admin/librivox/work` | | `librivoxFetch(lib, id)` | `POST /admin/librivox/fetch`, `202 {job}` | | `bridgeJob(id)` | `GET /admin/bridge/jobs/{id}` | @@ -163,8 +177,8 @@ again shows where; the body's `library_id` and `path` are not read), 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 +while open and the app is in the foreground (`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 @@ -179,16 +193,30 @@ found, and the opened work's recordings under it. - `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 job's end is handled by the fetch watcher (below), whether the page is open + or not. +- The status is `JobStatusLayout` (`src/components/bridge/job-status-layout.tsx`, + shared with Generate's `JobStatus`), with Cancel under it where Add sits. - 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. +**The fetch watcher** (`fetch-watcher.tsx`) follows a fetch past the page. Every +open job a row follows, and an Add's job as it answers, goes to +`watchLibrivoxFetch(cid, id)`, an in-memory list (nothing is persisted, so after a +restart the Library's stale time covers it). `LibrivoxFetchWatcher`, a root +listener, is the one poller: one `useLibrivoxJob(id, true, cid)` per watch, +whatever page is open, while the app is in the foreground. A row reads the same +query without polling. When a job ends, the watcher asks the works again, and for +a `done` one reads the library's lists and the connection's recently added again +(`invalidateLibraryLists`) and asks the works once more after `DONE_RECHECK_MS` +(the server records the book just after the job ends). It then drops the watch. +It also drops one that is gone (`404`), refused (`400`, `401`, `403`) or whose +connection signed out. The watcher keeps the hooks' `bridge` gate, so a lapsed +heartbeat pauses its reads and keeps the watch. + Strings are under `librivox.*` in all six locales, and fixtures are in `src/testing/librivox-fixtures.ts`. diff --git a/docs-developers/frontend/routes.md b/docs-developers/frontend/routes.md index e61042ad..e22632be 100644 --- a/docs-developers/frontend/routes.md +++ b/docs-developers/frontend/routes.md @@ -30,7 +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. | +| `/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, and a link without `connection` is the default connection's. | | `/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 24995ed6..f35e31e3 100644 --- a/docs-developers/frontend/state-and-data.md +++ b/docs-developers/frontend/state-and-data.md @@ -560,7 +560,7 @@ 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) | +| `librivoxSearch(q)` / `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 diff --git a/docs-developers/index.md b/docs-developers/index.md index e64d7763..9a1e6242 100644 --- a/docs-developers/index.md +++ b/docs-developers/index.md @@ -108,8 +108,9 @@ reserved for it. (`/connect#code=…`), so it never reaches server logs. The connect page redeems it with `POST /auth/redeem`, which returns a pairing token that keeps the invite's remaining uses and expiry. The page then offers two carriers: an HTTPS `web_url` -(for the QR code and Universal Links) and an `audiosilo://connect?...` custom-scheme -deep link. The app or web player exchanges the pairing token for a device-scoped +(for the QR code: the app's scanner reads it, and a phone's camera app opens the +web player) and an `audiosilo://connect?...` custom-scheme deep link. The app or +web player exchanges the pairing token for a device-scoped session (`POST /auth/exchange`), claiming one invite use per device. So each device being set up can scan the same QR code. diff --git a/docs-developers/server/api/reference.md b/docs-developers/server/api/reference.md index 33431ca6..b36ff8b2 100644 --- a/docs-developers/server/api/reference.md +++ b/docs-developers/server/api/reference.md @@ -287,10 +287,14 @@ Response `200`: the pairing payload (`PairingPayload` in `internal/api/qr.go`): "qr_png_data_uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg…", "links": { "web": "https://books.example.com/web", - "admin": "https://books.example.com/admin" + "admin": "https://books.example.com/admin", + "ios": "https://apps.apple.com/us/app/audiosilo/id6783431375", + "android": "https://play.google.com/store/apps/details?id=app.audiosilo", + "apk": "https://github.com/KodeStar/audiosilo-frontend/releases/latest" }, "code_expires_at": "2026-07-04T09:30:00Z", "uses_remaining": 5, + "admin": true, "addresses": { "home": "http://192.168.1.20:8080", "away": "https://books.example.com" @@ -311,11 +315,20 @@ The other fields: - `code_expires_at` and `uses_remaining` describe the parent invite's budget. They are advisory, because concurrent exchanges may consume uses after the redeem. Both are omitted for recovery codes and unlimited invites. -- `web_url` is what the QR encodes. On claimed domains it opens the native app - through Universal Links or App Links, and otherwise the embedded web player. +- `web_url` is what the QR encodes. The app's own scanner reads the token from + it. A phone's camera app opens it in the browser, at the embedded web player, + because the store apps claim no domain: a self-hosted server's domain is its + own. Only a build named in `app_links` opens from it + ([Well-known](#well-known)). - `uri` is the custom-scheme equivalent, for an explicit "Open in app" action. -- `links.ios` and `links.android` (store links) are omitted until the store apps - ship. +- `links` says where to get the player: `web` and `admin` on this server, `ios` + (the App Store), `android` (Google Play) and `apk` (the player's latest GitHub + release page, never a file, for an Android phone without Google Play). `apk` + is additive. The connect page shows the same store links before any redeem. +- `admin` is `true` when the code pairs an admin's account (an admin's invite, + or an admin's recovery code), so the connect page offers the console only to + an admin. It is absent for a listener's code, and on every other pairing + payload (`/auth/pair`, the demo). - `base_url` honors the configured `public_url`, falling back to the request host. - `server_name` is the server's display name, as `GET /server`'s `name`. - `server_id` is its stable id, as `GET /server`'s `server_id`. Both links end with @@ -486,7 +499,7 @@ device from an existing session. The body is optional: ``` Response `200`: a `PairingPayload`, the same shape as `/auth/redeem` without the -invite budget fields. Its token is single-use and lasts 10 minutes (`pairingTTL`), +invite budget fields and `admin`. Its token is single-use and lasts 10 minutes (`pairingTTL`), so the player counts down from when the payload arrived. `ttl_seconds` (from 2.4.0) asks for a shorter life, clamped to 30 seconds through @@ -5629,7 +5642,7 @@ remain as history). Body optional: |---|---|---|---| | `label` | string | `""` | display label | | `max_uses` | int | `5` | explicit `0` = unlimited (negative values are clamped to 0) | -| `ttl_days` | int | `1` | explicit `0` = never expires | +| `ttl_days` | int | `1` | explicit `0` = never expires. The console's invite dialog asks for `7` unless changed | Response `201`, shown once: @@ -11793,10 +11806,11 @@ Adds a LibriVox project to a books library: { "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. +The server resolves the project's work, recording and title with metaserve's +`lookup?librivox_id=`, and queues `librivox.fetch` with them in its params +(`{librivox_id, work_id, recording_id, library_id, title}`; `title` is optional +and additive). A lookup that names no recording is `not_found`: the server never +takes a work's first recording instead. | Status | Meaning | |---|---| @@ -11813,11 +11827,20 @@ as `bridge.job` (with `job`, `kind`, `library` and `librivox_id`). 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). +ends done (named by the book its result names), failed or parked (named by the +params' `title` when no book was placed). Its params, progress steps, result and +failure codes are in [The job](../../bridge/librivox.md#the-job-librivoxfetch). + +The result is `{library_id, path, librivox_id, files, bytes, adopted}`. `adopted` +is additive: `false` when the Bridge placed the folder in this run, `true` when it +found the book already placed, absent from an older Bridge. + 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. +job's own library. `adopted` decides whether that replaces a match already at +the path ([The book's identity](../../bridge/librivox.md#the-books-identity)). +Its `bridge_job_finished` event carries `librivox_match` (`matched`, `kept` or +`unmatched`; see [Bridge event data](../backups-and-notifications.md#bridge-event-data)). ## Well-known diff --git a/docs-developers/server/auth-and-security.md b/docs-developers/server/auth-and-security.md index ca0567cc..e30ca540 100644 --- a/docs-developers/server/auth-and-security.md +++ b/docs-developers/server/auth-and-security.md @@ -453,7 +453,7 @@ mistyped entry still verifies. | | `invite` | `recovery` | |---|---|---| | Minted by | Admin (`POST /admin/users/{id}/authcode`) | The user themself (`POST /auth/recovery`) | -| Defaults | 5 uses, 1-day expiry (`defaultAuthCodeMaxUses` and `defaultAuthCodeTTLDays` in `handlers_admin.go`). An explicit `0` means unlimited uses or no expiry. | Unlimited uses, never expires | +| Defaults | 5 uses, 1-day expiry (`defaultAuthCodeMaxUses` and `defaultAuthCodeTTLDays` in `handlers_admin.go`). An explicit `0` means unlimited uses or no expiry. The console's invite dialog asks for 7 days unless changed. | Unlimited uses, never expires | | Cardinality | One *active* invite per user | At most one recovery code per user | | Listable | Yes (`ListAuthCodes`, metadata only) | No. It shows only as `User.HasRecovery` | | Revocation | `DELETE /admin/authcodes/{id}` | `DELETE /auth/recovery` (self) or `DELETE /admin/users/{id}/recovery` (admin). A password change or reset, or Sign out everywhere, also removes a leaked code (see [Password changes end sessions](#password-changes-end-sessions)). | diff --git a/docs-developers/server/backups-and-notifications.md b/docs-developers/server/backups-and-notifications.md index 3eb820bd..d571b28f 100644 --- a/docs-developers/server/backups-and-notifications.md +++ b/docs-developers/server/backups-and-notifications.md @@ -343,7 +343,10 @@ Both Bridge events carry: - `job_id`, `job_kind`, `state`, `library`, `library_id`, `path`; - `title`: the book's title as indexed now, one plain line of at most 200 - characters, or `""` when it can't be read. + characters, or `""` when it can't be read. A `librivox.fetch` that placed no + book (failed, parked, or a done one whose result can't be read) carries its + params' `title`, the work it was adding, and so does one whose book has no + title. `bridge_job_finished` adds per-kind fields: @@ -353,7 +356,12 @@ Both Bridge events carry: - a `sidecars.contribute` job that filed nothing because the community already had everything: `shared_nothing: true`; - a `readalong.align` job: `coverage`, its result's `coverage.text` (0 to 1), - when the result says. + when the result says; +- a `librivox.fetch` job: `librivox_match`, how its book is matched once the + server's fold has run (`catalog.LibrivoxFetchMatch`): `matched` (to the job's + recording), `kept` (an adopted book that keeps another match) or `unmatched` + (neither, or a result the fold refused). Absent when the match can't be read. + See [The notification](../bridge/librivox.md#the-notification). `bridge_job_failed` fires when a job moves **into** `failed`, either from a Bridge's report or from a lease that ran out on the last attempt. It also fires @@ -399,7 +407,7 @@ 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: ` | | | `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." | +| `librivox.fetch` | `Added from LibriVox: <title>`, the book its result names | by `librivox_match`: `matched` "It is in the library now, matched to its LibriVox recording."; `kept` "It is in the library now. It was there already and keeps the match it had."; `unmatched` "It is in the library now, but it couldn't be matched to its LibriVox recording. Add it again from Library > LibriVox to match it.", sent as a warning (the `warning` tag, the warning colour on Discord); none "It is in the library now." | | `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." | @@ -409,8 +417,12 @@ of their own (`FINISHED_OWN` in `lib/server-events.ts`). A read-along reads `Rea `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. A parked `librivox.fetch` words its code in the -bell as Library > LibriVox does (`fetchParkKey`). +with the kind's name under it. A `librivox.fetch` whose `librivox_match` is +`kept` reads "It was in the library already and keeps the match it had." under its +title, and an `unmatched` one reads "It couldn't be matched to its LibriVox +recording. Add it again from Library > LibriVox to match it." in the warning tone. +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 diff --git a/docs-developers/server/data-model.md b/docs-developers/server/data-model.md index 163049b8..0ddb73df 100644 --- a/docs-developers/server/data-model.md +++ b/docs-developers/server/data-model.md @@ -1822,8 +1822,11 @@ Two things write it: `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)). + offered identifiers. By the result's `adopted`, it replaces a row already at + that path (a placed book, which also drops the path's leftover ASIN and ISBN), + keeps it (an adopted book), or replaces it and drops nothing (no `adopted`, an + older Bridge). It touches no other path (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 diff --git a/docs-developers/server/web-ui.md b/docs-developers/server/web-ui.md index 756a2609..f5cf9b04 100644 --- a/docs-developers/server/web-ui.md +++ b/docs-developers/server/web-ui.md @@ -294,7 +294,9 @@ The console runs under the same `script-src 'self'; style-src 'self'` policy ESLint rule bans importing `zod` anywhere else. - **Invite QR codes are drawn in the browser** (uqr, rendered as inline SVG elements), so a fresh invite code never travels back to the server inside an - image request. + image request. The QR encodes the invite link itself (`<base>/connect#code=…`): + the app's scanner signs in with its code, and a phone's camera app opens the + connect page. ### Covers @@ -834,7 +836,12 @@ library through the Bridge's `librivox.fetch`. The design is in 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`). + as a toast (`errors.librivoxInLibrary` for `409 in_library`, + `librivox.add.notFound` for `404 not_found`, a project the metadata no longer + lists). +- **The open job's row** says which library it is going into when the server has + several books libraries ("Adding to <library>", `librivox.recording.addingTo`), since the open job is the + project's in any 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 @@ -883,8 +890,21 @@ Any other failure keeps the dialog open with its message. `GET /admin/sessions/live`, else from their newest unfinished progress in `GET /admin/stats`. The devices line comes from `GET /admin/devices`, sessions only. -- **The invite flow** creates a password-less member, grants access, mints an - invite, and shows the QR, link and code once. +- **The invite flow** creates a member, grants access, mints an invite, and shows + the QR, link and code once. The member has no password unless the dialog's + optional one is filled in (at least `MIN_PASSWORD`, 8, sent with + `POST /admin/users`; it never goes in the link or the share text). The dialog + asks for 7 days unless changed (`ttl_days`; the API's own default is 1). The + card's **Share** uses the Web Share API with a short message and the link, and + copies them where there is none (`shareOrCopy`, `lib/clipboard.ts`; browsers + offer it only on https). `inviteReach` (`people-model.ts`) reads the link's + host for three notices: loopback (only this computer), a home address by + `isHomeHost`, the server's own rule (a private or link-local IP, a `.local`, + `.lan`, `.home.arpa` or `.internal` name, or a single-label name; this network + only, the info tone), and `https://` at an IP address (a self-signed + certificate warning). A public host name gets none of them. The link's base is + `baseURL`: the public address, else the address the console was opened at, so + a home address set in Settings does not change it. - **A person's page** is `/admin/people/user/<id>`, with its tab in `?tab=`: listening (the default, no param), access, devices, invites, sign-in, account. @@ -1445,8 +1465,9 @@ 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 jobs.** A `librivox.fetch` reads "Adding from LibriVox", with the +work's title from its params (`librivoxTitle`), else 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. @@ -2023,7 +2044,7 @@ sequenceDiagram U->>W: open /connect#35;code=XXXX W->>W: read #35;code from fragment,<br/>history.replaceState() strips it from the URL bar W->>S: POST /api/v1/auth/redeem {code} - S-->>W: PairingPayload {qr_png_data_uri, uri, web_url} + S-->>W: PairingPayload {qr_png_data_uri, uri, web_url, admin?} W->>U: show QR + "Open in app" + "Open web player" ``` @@ -2036,13 +2057,27 @@ code and honors its uses and expiry. The page therefore tells the user that each device can scan the QR. For a bounded invite it also says how many devices it can still pair, from `uses_remaining`. +What the page shows after a redeem: + +- **Admin section** only when the payload's `admin` is `true`, an admin's code. + A listener has no use for the console. +- On a phone or tablet (`isPhone`: `navigator.userAgentData.mobile`, a mobile + user agent, or an iPad that reports a Mac with a touch screen), no QR, since + the phone holding it can't scan it. `showPhone` leads with "Get the app, then + come back to this link", moves the store links under it, and puts **Open web + player** first. +- Everywhere, a **Get the AudioSilo app** block with the App Store, Google Play + and APK links. They are static in `index.html`, so they show before any + redeem too, and match the payload's `links` (`TestConnectPageStoreLinks`). + The redeem response (`PairingPayload`, built by `buildPairing` in `internal/api/qr.go`) carries the pairing token in two forms: - **`web_url`** = `<base>/web/connect?token=…`, which is what the **QR - encodes**. Scanning it opens the native app when the app claims the domain - (Universal Link or App Link). Otherwise it opens the embedded web player's - connect route, which exchanges the token. + encodes**. The app's own scanner (**Scan a QR code**) reads the token from it. + A phone's camera app opens it in the browser, at the embedded web player's + connect route, which exchanges the token. The store apps claim no domain, so + only a build named in `app_links` opens from it. - **`uri`** = `audiosilo://connect?server=…&token=…`, behind the custom-scheme "Open in app" button. Custom schemes are not domain-bound, so this launches an installed app on any self-hosted domain. diff --git a/docs-users/admin/books.md b/docs-users/admin/books.md index f29405ed..0dc91740 100644 --- a/docs-users/admin/books.md +++ b/docs-users/admin/books.md @@ -63,6 +63,18 @@ To match many books at once, use **Match automatically** on the Health page If the dialog says "meta.audiosilo.app isn't answering", try again in a minute. +If the book isn't in the community database at all, it stays unmatched and +plays as normal. The database has gaps. To add a missing book to it, see +[Contributing](../community/meta-site.md#contributing). + +The community database holds books, their recordings, authors, narrators and +series. Its users add and correct them, and many entries came from a public +Audible catalogue. When a recording sells in several Audible stores, the +**Audible marketplace** setting picks which store's edition a match takes (see +[Which ASIN a match takes](health.md#which-asin-a-match-takes)). Every unmatched +book is listed under **Not matched to community metadata** in +[Library health](health.md#the-kinds-of-issue). + ### What matching sends The server sends search terms to AudioSilo Meta only when you open **Match diff --git a/docs-users/admin/health.md b/docs-users/admin/health.md index 1564b367..df9addf6 100644 --- a/docs-users/admin/health.md +++ b/docs-users/admin/health.md @@ -128,6 +128,10 @@ doesn't have the book. It is listed under **Not found**. You can still match it by hand. The [match dialog](books.md#matching-with-community-metadata) lists the closest candidates, however low they scored. +The community database has gaps, so some books stay unmatched. They still play +and keep the details from their tags and folders. To add a missing book to the +database, see [Contributing](../community/meta-site.md#contributing). + ### ASIN and ISBN tags are hints An ASIN or ISBN in a book's tags is only a hint for a match, because tags are @@ -143,7 +147,7 @@ apply. Correct the tag, or match the book by hand, to stop it coming back. An ASIN from an Audiobookshelf `metadata.json` counts as the book's own straight away (see -[Coming from Audiobookshelf](../getting-started/organizing-your-library.md#coming-from-audiobookshelf)). +[Audiobookshelf's metadata.json files](../getting-started/organizing-your-library.md#coming-from-audiobookshelf)). ### Store listings with no ASIN or ISBN diff --git a/docs-users/admin/libraries.md b/docs-users/admin/libraries.md index eb2be960..6e4cdb6a 100644 --- a/docs-users/admin/libraries.md +++ b/docs-users/admin/libraries.md @@ -127,7 +127,7 @@ Some folders hold audio that isn't an audiobook, such as sample clips or an | `Extras/` | every folder named `Extras`, wherever it is (the trailing `/` means folders only) | | `Podcasts/*` | everything inside the `Podcasts` folder at the top of the library | | `/Extras` | only the `Extras` folder at the top of the library | -| `metadata.json` | the `metadata.json` files Audiobookshelf leaves in book folders (see [Coming from Audiobookshelf](../getting-started/organizing-your-library.md#coming-from-audiobookshelf)) | +| `metadata.json` | the `metadata.json` files Audiobookshelf leaves in book folders (see [Audiobookshelf's metadata.json files](../getting-started/organizing-your-library.md#coming-from-audiobookshelf)) | | `# publisher samples` | nothing: a line starting with `#` is a comment | `*` stands for any run of characters and `?` for one character. Upper and lower diff --git a/docs-users/admin/librivox.md b/docs-users/admin/librivox.md index 37e33846..b2693eba 100644 --- a/docs-users/admin/librivox.md +++ b/docs-users/admin/librivox.md @@ -38,8 +38,9 @@ To add a book, open **Library > LibriVox** in the admin console. 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. +The recording's row shows the Bridge's progress, and the library it is going +into when you have more than one. 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. @@ -62,8 +63,14 @@ books library. 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. +The row shows the progress, with **Cancel** under it while it runs. Once the +book is in, the row says **In your library**, and **Open** opens its page. + +You can leave the page while the book downloads. While the app stays open, the +Library shows the book as soon as it is in. + +If the Bridge goes offline while the page is open, the page stays and says so. +You can keep searching, and **Add** and **Cancel** wait until the Bridge is back. ## What lands in your library @@ -102,8 +109,12 @@ waiting for does not wait. **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. +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. That holds +when archive.org stops answering too, so adding the book again later carries on. + +A download left unfinished and unused for 7 days is cleared from the Bridge's +hidden folder. ## When adding fails @@ -122,7 +133,14 @@ The row says why. Press the add button (**Add to** *your library*) again to try ## Good to know - The server tells you when a book is added or fails, in the bell and your - [notifications](notifications.md). + [notifications](notifications.md). The message says how the book is matched: + - **matched to its LibriVox recording**, the usual case; + - **kept the match it had**, when the book was in the library already with + another match; + - **couldn't be matched**, sent as a warning. Add the recording again from + **Library > LibriVox** to match it. +- A link to **Find on LibriVox** for a library this server doesn't have shows + **This link doesn't point to anything here.** - [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 diff --git a/docs-users/admin/users-and-invites.md b/docs-users/admin/users-and-invites.md index c4287308..cb880ab4 100644 --- a/docs-users/admin/users-and-invites.md +++ b/docs-users/admin/users-and-invites.md @@ -23,28 +23,51 @@ needs an admin. 1. Click **Invite someone** (on the People or Invites page). 2. Enter **Their name**. -3. Choose **What can they listen to?**: **All libraries**, one library, one of +3. Optionally, enter a **Password (optional)** of at least 8 characters. It + lets them sign in with their name and password too, in the web player or the + app. Leave it empty and they pair their devices with the link only. +4. Choose **What can they listen to?**: **All libraries**, one library, one of your [shares](sharing.md), or **Decide later**. -4. Choose how many **Devices it can pair** (5 by default) and when it - **Expires after** (1 day by default). -5. Click **Create invite**. +5. Choose how many **Devices it can pair** (5 by default) and when it + **Expires after** (7 days by default). +6. Click **Create invite**. -![A freshly created invite](/img/screenshots/admin/invite.png) +![A freshly created invite: the QR code card, Share, and the notice that only this computer can open a localhost link](/img/screenshots/admin/invite.png) The invite shows a **QR code**, an **Invite link** and the **Code** on its own. -Send them the link, or let them scan the QR code. They can use the same link on -each of their devices until it runs out. See -[Connecting](../listening/connecting.md) for what they see. + +- **Share** opens your device's share sheet with a short message and the link. + Where the browser has no share sheet, it copies them instead. +- They can use the same link on each of their devices until it runs out. +- To scan the QR code, they open the AudioSilo app and tap **Scan a QR code**. + The phone's camera app opens the invite page in the browser instead, which + doesn't sign the app in. + +See [Connect the app to your server](../listening/connecting.md#if-someone-invited-you) +for what they see, on a computer and on a phone. The code is shown only this once. To send a fresh one later, [rotate](#rotating-and-revoking) the invite. -The new account has no password and isn't an admin. Most listeners never need a -password, because they pair their devices instead. +The new account isn't an admin. **All libraries** gives them each library you have now. Give them any library you add later from their page. +### When the link only works nearby + +The invite link uses the server's public address, else the address you opened +the console at. A notice under **Share** says when that limits who can open it: + +- **Only this computer can open this link**: the link points at `localhost`. + Set a public address in [Server > Settings > General](server.md#general), or + open the console at the server's network address, then make a new invite. +- **Works on your home network only**: to invite someone elsewhere, set a + public address. +- **Their browser will warn about the certificate**: the link is `https://` at + an IP address. They can continue past the warning, or you can set a public + address with a real certificate. + ## The People page ![The People page: person cards, with one household grouped into a block](/img/screenshots/admin/people.png) @@ -60,7 +83,8 @@ A person's page has six tabs: **Listening**, **Access**, **Devices**, **Invites**, **Sign-in** and **Account**. **Pair a device** in the header makes a new invite for this person, for a new -phone or a lost link. It replaces any invite they already have. +phone or a lost link. It replaces any invite they already have. Your own page's +**Pair a device** is where you find a QR code for your own phone. ### Listening diff --git a/docs-users/features.md b/docs-users/features.md index 8373e152..69658dab 100644 --- a/docs-users/features.md +++ b/docs-users/features.md @@ -72,6 +72,7 @@ Everything else works with the server alone. - Install with [Docker](getting-started/quickstart-docker.md), on [Unraid](getting-started/install-unraid.md), on a [Synology NAS](getting-started/install-synology.md), or [without Docker](getting-started/install-binary.md). - [Set up your server](getting-started/first-run.md): the first-run checklist. +- [Move over from Audiobookshelf](getting-started/coming-from-audiobookshelf.md): read the same folders, then bring book details and listening history across. - [Reach your server from outside your home](getting-started/remote-access.md): a domain name and a trusted certificate. - [Back up your server](admin/backups.md): scheduled and manual backups, and restore. - [Notifications](admin/notifications.md): new books, failed scans and sign-ins, on your phone, Discord or a webhook. diff --git a/docs-users/getting-started/coming-from-audiobookshelf.md b/docs-users/getting-started/coming-from-audiobookshelf.md new file mode 100644 index 00000000..5694ad70 --- /dev/null +++ b/docs-users/getting-started/coming-from-audiobookshelf.md @@ -0,0 +1,59 @@ +--- +title: Move over from Audiobookshelf +description: "Point AudioSilo at the books folder you use with Audiobookshelf: bring its book details and listening history across, match your books, and know what reads differently." +--- + +AudioSilo can read the same books folder as Audiobookshelf, so you can try it without moving a file. This page covers what comes across, what to do after the first scan, and where the two read a folder differently. You need an admin account. + +## Run both side by side + +The AudioSilo server never writes to your books folder. It can read the folder while Audiobookshelf keeps running, and you can mount the folder read-only. Nothing you do in AudioSilo changes Audiobookshelf. + +## Bring your book details across + +Audiobookshelf keeps each book's details, such as its matched ASIN, your edits and its series, in a `metadata.json` file. By default it keeps these files in its own metadata folder, not beside your books, so AudioSilo can't see them. Its **Store metadata with item** setting puts the file in each book's folder instead, and it is off by default. + +Turning the setting on doesn't write files for the books already there. Audiobookshelf writes a book's file into its folder the next time it saves a change to that book. To bring a book's details across: + +1. In Audiobookshelf, open **Settings** and turn on **Store metadata with item**. +2. In Audiobookshelf, change the book, for example edit its details or match it. Audiobookshelf writes its `metadata.json` beside it. +3. In the AudioSilo admin console, [rescan the library](../admin/libraries.md#rescanning). + +A book with a `metadata.json` file then shows its Audiobookshelf details in AudioSilo, and one with an ASIN or ISBN in it counts as matched. See [Audiobookshelf's metadata.json files](organizing-your-library.md#coming-from-audiobookshelf) for what the file brings. + +## Bring listening history across + +**Server > Settings > Import** copies each person's listening sessions, progress and bookmarks from Audiobookshelf. [Invite](../admin/users-and-invites.md#inviting-someone) everyone first, so each person has an account to import into. See [Import your Audiobookshelf history](../admin/import.md). + +## Match your books + +A scan matches no book by itself, apart from books whose `metadata.json` names an ASIN or ISBN. An ASIN in a book's tags is only a hint for the next step. To match the rest: + +1. Open **Health** in the admin console and select **Not matched to community metadata**. This is the list of your unmatched books. +2. On its **Match automatically** card, pick the library and select **Find matches**. +3. Review what it found and apply it. See [Matching every book at once](../admin/health.md#matching-every-book-at-once). + +Matching uses the free community database at [AudioSilo Meta](https://meta.audiosilo.app). It is not a live copy of any store, so a book can be on Audible in your country and still be missing from it. [Matching with community metadata](../admin/books.md#matching-with-community-metadata) says what it holds. + +The **Audible marketplace** setting picks which store's edition a match takes. It is in [Server > Settings > Community metadata](../admin/server.md#community-metadata), and it uses the US store until you set yours. + +### When a book doesn't match + +The community database has gaps, so some books won't match. An unmatched book still plays as normal. It keeps the title, author, series and cover from its tags and folders. What it lacks is the community extras: the description, characters and recaps, and community chapters. + +- To match one book by hand, see [Matching with community metadata](../admin/books.md#matching-with-community-metadata). +- To add a missing book to the community database, see [Contributing](../community/meta-site.md#contributing). Anyone with a free GitHub account can add one. +- **Help complete the community database** sends the database the ASINs it doesn't know. See [Helping complete the community database](../admin/server.md#helping-complete-the-community-database). + +## Where your folders read differently + +AudioSilo reads one folder that holds audio files as one book. Two layouts can come out differently from Audiobookshelf: + +- **Several single-file books loose in one author or series folder** read as one book with several parts. Set that folder to **Separate books**. See [When a folder holds many separate books](organizing-your-library.md#when-a-folder-holds-many-separate-books). +- **A book split into disc folders**, such as `CD1` and `CD2`, reads as one book per disc. Join them with **Join into one book**. See [Books split into disc folders](../admin/libraries.md#books-split-into-disc-folders). + +**Health** lists both: **Folder may hold several books** and **One book split into disc folders**. + +## Connect your phones + +Install the AudioSilo app, open it and tap **Scan a QR code**. Your pairing code is in the admin console under **People**, your name, then **Pair a device**. See [Connect the app to your server](../listening/connecting.md). diff --git a/docs-users/getting-started/first-run.md b/docs-users/getting-started/first-run.md index 92c48176..8639dc0d 100644 --- a/docs-users/getting-started/first-run.md +++ b/docs-users/getting-started/first-run.md @@ -112,7 +112,9 @@ See [Reach your server from outside your home](./remote-access.md). ![Step 5: the Audiobookshelf import](/img/screenshots/setup/abs.png) -**Open the import** brings each person's listening progress across from Audiobookshelf (see [the import](../admin/import.md)). It opens the import in a new tab, and the flow stays open in this one. Audiobookshelf's `metadata.json` files are read as they are, with nothing to set. +**Open the import** brings each person's listening progress across from Audiobookshelf (see [the import](../admin/import.md)). It opens the import in a new tab, and the flow stays open in this one. + +AudioSilo reads the `metadata.json` files Audiobookshelf leaves beside your books. Audiobookshelf writes them there only with its **Store metadata with item** setting on, which is off by default. [Move over from Audiobookshelf](./coming-from-audiobookshelf.md) covers that, matching your books afterwards, and the folder layouts that read differently. ### Step 6: Backups and updates @@ -128,7 +130,7 @@ Copy backups to another disk now and then. See [Backups](../admin/backups.md). ![Step 7: a pairing QR code for your phone](/img/screenshots/setup/phone.png) -Scan the code with your phone's camera. It opens the app, or the web player, signed in as you. The code works once, for ten minutes; **New code** makes another. See [Connect the app to your server](../listening/connecting.md). +Install the AudioSilo app on your phone, open it and tap **Scan a QR code**, then scan this code. The app signs in as you. The phone's camera app opens the web player instead. The code works once, for ten minutes, and **New code** makes another. See [Connect the app to your server](../listening/connecting.md). If you opened the console on the server itself (`localhost`), the code needs a home or public address from [step 4](#step-4-how-will-people-reach-the-server). The step tells you if one is missing. diff --git a/docs-users/getting-started/organizing-your-library.md b/docs-users/getting-started/organizing-your-library.md index ac310575..199badbe 100644 --- a/docs-users/getting-started/organizing-your-library.md +++ b/docs-users/getting-started/organizing-your-library.md @@ -64,7 +64,7 @@ AudioSilo takes each book's details from these sources: - **Folder names.** The book's folder (or file) is the title, its parent folder the series, and the folder above that the author. A leading number like `03 - Title` is the book's place in the series (`02.5` works too). `Author/Title/` with no series folder works as well. - **The files' tags.** Title (usually the album tag), author, narrator, series and genres. AudioSilo ignores junk titles like "Track 01". -- **Audiobookshelf's `metadata.json`**, when the book's folder has one. See [Coming from Audiobookshelf](#coming-from-audiobookshelf). +- **Audiobookshelf's `metadata.json`**, when the book's folder has one. See [Audiobookshelf's metadata.json files](#coming-from-audiobookshelf). - **The audio itself**: durations and any chapters in the file. - **The community database**, for chapters only: a book with no chapters of its own gets them once it is [matched](../admin/books.md#community-chapters). @@ -84,16 +84,16 @@ With tags first, a series tag in the files still wins. With folder names first, Many tools put the series name in the album tag, so every book in a series has the same album. AudioSilo spots this and takes the book's own title from its title tag, file name or folder name instead. A book named after its series, like `Dune`, keeps the album as its title. -## Coming from Audiobookshelf +## Audiobookshelf's metadata.json files {/* #coming-from-audiobookshelf */} -Audiobookshelf can leave a `metadata.json` file in each book's folder (its **Store metadata with item** setting). AudioSilo reads it, so your matches and corrections from Audiobookshelf come across. +Audiobookshelf can leave a `metadata.json` file in each book's folder. It does so only with its **Store metadata with item** setting on, which is off by default (see [Move over from Audiobookshelf](coming-from-audiobookshelf.md)). AudioSilo reads the file, so your matches and corrections from Audiobookshelf come across. - Its title, authors, narrators, series, description, genres, ASIN, ISBN and similar details win over the tags. - A book with an ASIN or ISBN in the file counts as [matched](../admin/books.md#matching-with-community-metadata) straight away. - Your edits in the admin console still win over the file. - When the file changes, the next scan picks it up. -AudioSilo never writes to the file. To ignore these files, add `metadata.json` to the library's [skipped files](../admin/libraries.md#skipping-files-and-folders). To bring listening progress across, see [Import your Audiobookshelf history](../admin/import.md). +AudioSilo never writes to the file. To ignore these files, add `metadata.json` to the library's [skipped files](../admin/libraries.md#skipping-files-and-folders). ## Covers diff --git a/docs-users/getting-started/remote-access.md b/docs-users/getting-started/remote-access.md index aa3af6ec..58131117 100644 --- a/docs-users/getting-started/remote-access.md +++ b/docs-users/getting-started/remote-access.md @@ -81,7 +81,7 @@ The app stores the headers as securely as your sign-in. To change them, use the Good to know: -- **An `https://` address.** Proxy headers need an `https://` address, and the app doesn't add them to a plain-HTTP request. Requests to the server's other addresses, such as its home address, get none either. +- **One `https://` address.** The app sends a set of headers only to the `https://` address you entered them for. A home address that is `https://` can be that address. Requests to the server's other addresses get none, and a plain-HTTP address never does. - **Redirects:** when the proxy or the server redirects a request to another address, the Android app sends the headers on to that address (checked on Android; not yet on iOS). Only let the proxy redirect to addresses you'd trust with them. - **What can't send them:** Chromecast, the Apple Watch and Wear OS apps, and calendar apps. They reach the server by themselves, so they can't get past the proxy. In a browser, covers, audio and requests to another server don't carry them. Those rely on your sign-in to the proxy in that browser. - **Some names are the app's own**, such as `Authorization`, `Cookie`, `Host`, `Range` and `Content-Type`, and it refuses them. So a proxy that asks for basic auth (an `Authorization` header) can't sit in front of AudioSilo. diff --git a/docs-users/index.md b/docs-users/index.md index f503bdf8..52ced079 100644 --- a/docs-users/index.md +++ b/docs-users/index.md @@ -30,6 +30,7 @@ Want to look first? [Try the demo](demo.md) without installing anything. - [Listen offline](listening/offline-downloads.md) by downloading books to your device. - [Reach your server from outside your home](getting-started/remote-access.md). - [Invite people](admin/users-and-invites.md) or [add your household](listening/household.md). +- [Move over from Audiobookshelf](getting-started/coming-from-audiobookshelf.md). - Listen on your [Apple Watch](listening/apple-watch.md) or [Wear OS watch](listening/wear-os.md), [in the car](listening/in-the-car.md) or [on a Chromecast](listening/chromecast.md). - [Read the ebook](listening/reading-the-ebook.md) and [follow along with the narration](admin/making-read-along.md). - [Back up your server](admin/backups.md), and update it ([Docker](getting-started/quickstart-docker.md#update), [Unraid](getting-started/install-unraid.md#update), [without Docker](getting-started/install-binary.md#update)). diff --git a/docs-users/listening/account.md b/docs-users/listening/account.md index 70c0cb8d..75c0c2c0 100644 --- a/docs-users/listening/account.md +++ b/docs-users/listening/account.md @@ -30,7 +30,7 @@ Setting a first password signs nothing out. To sign in another phone, tablet or browser without a new invite: 1. Tap **Show a pairing code**. -2. Scan the QR code with the iOS or Android app on the other device, or with its camera, or send it the link. The code works once, for 10 minutes. +2. On the other device, tap **Scan a QR code** in the iOS or Android app and scan the code, or send the device the link. The device's camera app opens the web player instead. The code works once, for 10 minutes. 3. The other device signs straight in. If the code expires, tap **Make a new code**. diff --git a/docs-users/listening/connecting.md b/docs-users/listening/connecting.md index 891b8d0c..99b4db32 100644 --- a/docs-users/listening/connecting.md +++ b/docs-users/listening/connecting.md @@ -3,28 +3,41 @@ title: Connect the app to your server description: "Sign in to your AudioSilo server from the web player or the iOS and Android apps, with a QR code, an invite or a password." --- -Before you can listen, the iOS and Android apps or the web player must sign in to your server. If you run the server, scan a pairing code from the admin console. If someone invited you, open the invite link they sent. +Before you can listen, the iOS and Android apps or the web player must sign in to your server. In the app, a QR code is quickest. Install the app, tap **Scan a QR code** and scan the code your server shows, with nothing to type. You do this once per device. The app stays signed in until you sign out, your password changes, or an admin signs the device out. -## If you run the server +## Sign in the app with a QR code {/* #scanning-the-qr-code-with-your-phone */} -1. Sign in to the [admin console](../admin/console-tour.md). -2. Find a pairing code, a QR code that signs a device in. The first-time setup ends with a **Connect a phone** step that shows one. Later, open **People**, then your own page, and select **Pair a device**. -3. Point your phone's camera at the pairing code and tap the link it finds. +1. Install AudioSilo from the App Store or Google Play. See [Get the apps](mobile-apps.md). +2. Open the app and tap **Scan a QR code**. +3. Point the camera at your QR code. -The phone opens the app, or the web player, signed in as you. You can also sign in to the app with your admin username and password. See [First run](../getting-started/first-run.md#set-up-your-server). +The app signs in to the server and shows **Your library is ready**. + +Where to find your QR code: + +- **If you run the server**, open the [admin console](../admin/console-tour.md) and go to **People**, your name, then **Pair a device**. The **Connect a phone** step of first-time setup shows one too. +- **If someone invited you**, it is on your invite page, the page your invite link opens. Open the link on a computer and scan the code from there. + +Scan with the app, not with the phone's camera app. The camera app opens a web page instead. An invite's code opens the invite page, and other codes open the web player in the browser. + +Each device can scan the same invite's QR code, up to the invite's number of uses. ## If someone invited you 1. Open the invite link you were sent. It looks like `https://books.example.com/connect#code=…`. 2. The server's connect page signs the invite in. 3. Choose where to listen: - - **Open web player** - listen in this browser, with nothing to install. - - **Open in app** - sign in the app on this device. If nothing happens, the app isn't installed. See [Get the apps](mobile-apps.md). - - The QR code - scan it to sign in your other devices, such as your phone. + - The QR code: scan it with the app on your phone, as above. + - **Open in app**: sign in the app on this device. If nothing happens, the app isn't installed. + - **Open web player**: listen in this browser, with nothing to install. + +![The server's connect page with a pairing QR code, Open in app and Open web player, and the app store links](/img/screenshots/server/connect-page.png) -![The server's connect page with a pairing QR code and Open in app and Open web player buttons](/img/screenshots/server/connect-page.png) +Opened on a phone, the page leads with getting the app instead of a QR code. Install AudioSilo from the store links on the page, then open the invite link again and tap **Open in app**. Or tap **Open web player** to listen in the browser now. An Android phone without Google Play can download the app's APK from the link under the store buttons. + +![The invite page on a phone: get the app first, the store links, then Open web player and Open in app](/img/screenshots/server/phone-connect-invite.png) :::tip Anyone who opens your invite link can sign in as you, so don't post it publicly. @@ -32,30 +45,26 @@ Anyone who opens your invite link can sign in as you, so don't post it publicly. If the invite has expired or is used up, the page says so. Ask for a new one. The admin's side is in [Invite people](../admin/users-and-invites.md). -## Scanning the QR code with your phone - -Point your phone's camera at a pairing code and tap the link it finds. The phone opens signed in, in the web player or the app. - -Each device can scan the same invite's QR code, up to the invite's number of uses. If the app is installed, you can also tap **Scan a QR code** on its connect screen. Only the iOS and Android apps can scan. - ## Connecting from the app **In the web player** there is nothing to type. It already knows the server it came from, so tap **Sign in**. ![The web player's connect screen: the server it belongs to, with Sign in and Try the demo](/img/screenshots/web-player/connect.png) -**In the iOS and Android apps:** +**In the iOS and Android apps**, without a QR code: -1. Enter the server address, for example `books.example.com`, and tap **Continue**. +1. Under **Or enter the server address**, enter the address, for example `books.example.com`, and tap **Continue**. 2. Check the server the app found and tap **Sign in**. 3. Choose **Invite code** or **Username and password**. 4. Enter your code, or your username and password, and tap **Sign in**. +If you run the server, your admin username and password work here too. The sign-in step also has **No password? Scan a QR code instead**, which opens the scanner. + The app adds `https://` to the address. Type `http://` yourself for a server that only answers plain HTTP. Do the same at home for a server on its default self-signed certificate (see [Plain HTTP at home](../getting-started/remote-access.md#plain-http-at-home-how-the-phone-apps-connect)). If the server can't be reached, check the address for typos and that the server is on. A home address like `192.168.1.20:8080` only works on your home network. -Instead of an address, you can tap **Scan a QR code** in the apps, or **I have a pairing link** in the web player and paste the link. +The web player has no scanner. Instead, tap **I have a pairing link** and paste the link. An **invite code** looks like `9M4K-P2TQ-WX7V-3RHD`. Many accounts have no password, which is normal. You can [set one](account.md#password) whenever you like. @@ -89,7 +98,7 @@ A [password](account.md#password) is the most dependable way back in. With one, You don't need a new invite: -- On a signed-in device, open your [account page](account.md#pair-another-device) and tap **Show a pairing code**. Scan it with the new device within 10 minutes. +- On a signed-in device, open your [account page](account.md#pair-another-device) and tap **Show a pairing code**. Scan it with the app's **Scan a QR code** on the new device within 10 minutes. - Or sign in with your username and [password](account.md#password). - A watch signs in from your phone. See [Listen on your Apple Watch](apple-watch.md#setting-it-up) or [Listen on your Wear OS watch](wear-os.md#setting-it-up). diff --git a/docs-users/troubleshooting.md b/docs-users/troubleshooting.md index add46c0c..bd83c8b7 100644 --- a/docs-users/troubleshooting.md +++ b/docs-users/troubleshooting.md @@ -74,12 +74,16 @@ An older server may not understand what a newer one added to the database. Run t ### My invite code doesn't work -- **It expired or was used up.** Invites last 1 day and 5 uses by default. Ask the admin to **Rotate** it. +- **It expired or was used up.** An invite from the admin console lasts 7 days and 5 uses by default. Ask the admin to **Rotate** it. - **A newer invite replaced it.** Each person has one active invite. Use the newest one. - **Too many wrong tries.** The message says how long to wait. Then copy the code exactly. See also [When signing in doesn't work](./listening/connecting.md#when-signing-in-doesnt-work). +### Scanning the QR code opened a web page, not the app + +The phone's camera app opens a QR code in the browser. Open the AudioSilo app instead, tap **Scan a QR code** and scan the code there. See [Sign in the app with a QR code](./listening/connecting.md#scanning-the-qr-code-with-your-phone). + ### Everyone gets "Too many tries" at once The server is behind a reverse proxy that isn't in **Trusted proxies**, so every visitor looks like the same address. One device retrying an old sign-in then locks everyone out for a while. Add the proxy's address under **Server > Settings > Network & HTTPS > Trusted proxies** (see [Behind a reverse proxy](./getting-started/remote-access.md#behind-a-reverse-proxy)). It applies at once. diff --git a/screenshots/README.md b/screenshots/README.md index 971e93b3..8c5c0e66 100644 --- a/screenshots/README.md +++ b/screenshots/README.md @@ -266,16 +266,24 @@ 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 console's LibriVox shot (`admin/librivox.png`) comes 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`. It searches the live community +metadata (the demo server's default lookup, so it needs the network) for The Wind +in the Willows and opens its recordings in Library > LibriVox. + +The player's shot (`web-player/find-librivox.png`) is `capture-web.mjs`'s, so a +`SKIP_ADMIN=1` run takes it too. The demo server has no Bridge, so, as for Find +shows, a Playwright route on the shot's own context adds an online Bridge offering +`librivox` to `/server`'s answer. The admin signs in (Browse the library, so +nothing plays), opens Find on LibriVox from the Books mode's **...** menu and runs +the same search, against the server's real search and work answers. Nothing is +added in either shot, so no job is queued and no seeded book gains a match; the +player's session is signed out again. + +The invite on a phone (`server/phone-connect-invite.png`) opens the connect page +shot's invite link again in an iPhone context (the page decides by the user agent). +Redeeming spends no use, so the same code works twice. The generated description shots (`admin/book-description.png`, `web-player/book-about-generated.png`, Bridge Phase 1c) need a book with a diff --git a/screenshots/capture-admin.mjs b/screenshots/capture-admin.mjs index 3e24780b..713c858c 100644 --- a/screenshots/capture-admin.mjs +++ b/screenshots/capture-admin.mjs @@ -41,21 +41,19 @@ import { CACHE, DAY, HOLMES_MATCH, + LIBRIVOX_QUERY, MIN, + PHONE, addHousehold, apiClient, around, - drawerClosed, fitViewportToPage, isoAgo, pathQuery, - playerSession, - probeServer, showDockerPaths, silence, sleep, shoot, - signInWithPassword, stamp, step, submitSignIn, @@ -1131,7 +1129,7 @@ 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']; +const LIBRIVOX_SHOTS = ['admin/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); @@ -1546,12 +1544,8 @@ await step('bridge: make a read-along', async () => { // 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'; +// recordings and opens it beside the results. Nothing is added: no job is queued, +// so no book gains a match. The player's Find on LibriVox is capture-web.mjs's. await step('bridge: add from LibriVox', async () => { if (!wanted(...LIBRIVOX_SHOTS)) return; if (!bridgeToken || !bridgeStatus) throw new Error('no stand-in Bridge was paired'); @@ -1560,52 +1554,17 @@ await step('bridge: add from LibriVox', async () => { 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 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'); }); await step('webhook deliveries', async () => { @@ -1772,6 +1731,32 @@ await step('connect page', async () => { await p2.close(); }); +// The same invite opened on a phone: the page reads the user agent (connect.js isPhone), +// so this context is an iPhone's. It leads with getting the app and the web player, and +// shows no QR code. Redeeming spends no use, so the same code works again here. +await step('connect page on a phone', async () => { + if (!wanted('server/phone-connect-invite.png')) return; + if (!inviteCode) throw new Error('no invite code was minted'); + const phone = await browser.newContext({ + ...DESKTOP_CONTEXT, + viewport: PHONE, + isMobile: true, + hasTouch: true, + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1', + }); + try { + const p3 = await phone.newPage(); + await p3.goto(`${ORIGIN}/connect#code=${inviteCode}`, {waitUntil: 'networkidle', timeout: 45000}); + await p3.locator('#phone-lead').waitFor({state: 'visible', timeout: 15000}); + // Tall enough for the whole page (the store links end it), never shorter than a phone. + await fitViewportToPage(p3, PHONE.height, 1600, 800, PHONE.width); + await shoot(p3, 'server/phone-connect-invite.png'); + } finally { + await phone.close(); + } +}); + await ctx.close(); await browser.close(); hook.closeAllConnections(); diff --git a/screenshots/capture-web.mjs b/screenshots/capture-web.mjs index d5384e65..0f5f7e98 100644 --- a/screenshots/capture-web.mjs +++ b/screenshots/capture-web.mjs @@ -33,6 +33,7 @@ import { DESKTOP_CONTEXT, FIXTURES, HOLMES_MATCH, + LIBRIVOX_QUERY, HOUR, MIN, PHONE, @@ -1537,16 +1538,27 @@ function findShowsDirectory(pod) { return {results, art}; } +// Stand in for part of the real `/server` answer on `ctx`: `patch` edits the parsed body. +async function routeServerInfo(ctx, patch) { + const origin = new URL(BASE).origin; + await ctx.route( + (url) => url.origin === origin && url.pathname === '/api/v1/server', + async (route) => { + const res = await route.fetch(); + const info = await res.json(); + patch(info); + return route.fulfill({response: res, json: info}); + }, + ); +} + // Route the Find shows answers on `ctx` (see above). Returns the stand-in's state. async function routeFindShows(ctx, pod, stand) { const origin = new URL(BASE).origin; const at = (p) => (url) => url.origin === origin && url.pathname === `/api/v1${p}`; const json = (route, status, body) => route.fulfill({status, contentType: 'application/json', body: JSON.stringify(body)}); - await ctx.route(at('/server'), async (route) => { - const res = await route.fetch(); - const info = await res.json(); + await routeServerInfo(ctx, (info) => { info.capabilities = {...info.capabilities, podcasts: true, podcast_discovery: true, podcast_opml: true}; - return route.fulfill({response: res, json: info}); }); await ctx.route(at('/podcasts/discovery/providers'), (route) => json(route, 200, {providers: [{id: 'itunes', available: true}, {id: 'podcast_index', available: false}]}), @@ -2539,6 +2551,48 @@ if (wanted(...Object.values(UPCOMING_SHOTS))) { } } +// ── Find on LibriVox (LibriVox L6) ────────────────────────────────────────── +// The player offers Find on LibriVox only while `/server` names an online Bridge whose +// features list `librivox`, and the demo server has no Bridge. As Find shows does, the +// shot stands in for that part of `/server` with a Playwright route on its own context; +// the search and the work are the server's real answers (live community metadata). +// Nothing is added, so no job is queued. +const LIBRIVOX_SHOT = 'web-player/find-librivox.png'; +if (wanted(LIBRIVOX_SHOT)) { + await step('find on librivox', async () => { + if (!ADMIN_PASSWORD) throw new Error('ADMIN_PASSWORD not set: Find on LibriVox is an admin page'); + const origin = new URL(BASE).origin; + const ctx = await newCaptureContext(); + await routeServerInfo(ctx, (info) => { + info.capabilities = {...info.capabilities, bridge: true}; + info.bridge = {version: '0.7.0', features: [...(info.bridge?.features ?? []), 'librivox']}; + }); + let session = null; + try { + const page = await ctx.newPage(); + await page.goto(BASE + 'connect', {waitUntil: 'networkidle', timeout: 45000}); + await probeServer(page, origin); + await signInWithPassword(page, 'admin', ADMIN_PASSWORD); + await tid(page, 'ready-screen').waitFor({timeout: 15000}); + session = (await playerSession(page)).token; + // Browse the library, not Start listening: nothing plays, so no session is recorded. + await firstVisible(page.getByRole('button', {name: 'Browse the library'})).click({timeout: 8000}); + await tid(page, 'top-bar-(library)').waitFor({timeout: 15000}); + await tid(page, 'books-menu').click({timeout: 20000}); + await tid(page, 'books-find-librivox').click({timeout: 8000}); + await tid(page, 'librivox-search-input').fill(LIBRIVOX_QUERY); + await tid(page, 'librivox-search-submit').click(); + await tid(page, 'librivox-hit').click({timeout: 30000}); + await tid(page, 'librivox-recording').waitFor({timeout: 30000}); + await tid(page, 'librivox-add').waitFor({timeout: 15000}); + await settleShoot(page, LIBRIVOX_SHOT, SETTLE_MS); + } finally { + if (session) await api(session, 'POST', '/auth/logout').catch(() => {}); + await ctx.close(); + } + }); +} + // ── Unauthenticated screens ───────────────────────────────────────────────── console.log('== public screens =='); // The connect flow on a fresh browser: the start screen with the probe card (the served diff --git a/screenshots/lib.mjs b/screenshots/lib.mjs index 4ccee0b2..ae88a1a6 100644 --- a/screenshots/lib.mjs +++ b/screenshots/lib.mjs @@ -97,6 +97,10 @@ export const DESKTOP_CONTEXT = { // The phone viewport (a large iPhone, in CSS pixels) every phone shot uses. export const PHONE = {width: 430, height: 932}; +// The work both LibriVox shots search for (the console's Add from LibriVox and the +// player's Find on LibriVox), so they show the same search: one with many recordings. +export const LIBRIVOX_QUERY = 'The Wind in the Willows'; + // A seeded book to match for a shot that needs the community's view of a series: the // seeded books carry no ASIN. This is the US ASIN of a recording of The Adventures of // Sherlock Holmes the live catalogue files under that work (b-j-harrison-2014), so its @@ -104,12 +108,12 @@ export const PHONE = {width: 430, height: 932}; // Series shot and the player's upcoming pass set it for their shots and revert it. export const HOLMES_MATCH = {author: 'Sir Arthur Conan Doyle', title: 'Adventures of Sherlock Holmes', asin: 'B00OQT6ZJ6'}; -// Makes the viewport as tall as the page (between min and max, at the desktop -// width), so a long card or every card is in the shot; the caller restores +// Makes the viewport as tall as the page (between min and max, at `width`, the desktop +// width unless given), so a long card or every card is in the shot; the caller restores // DESKTOP_CONTEXT.viewport. -export const fitViewportToPage = async (p, min, max, settle = 800) => { +export const fitViewportToPage = async (p, min, max, settle = 800, width = DESKTOP_CONTEXT.viewport.width) => { const height = await p.evaluate(() => document.documentElement.scrollHeight); - await p.setViewportSize({width: DESKTOP_CONTEXT.viewport.width, height: Math.min(Math.max(height, min), max)}); + await p.setViewportSize({width, height: Math.min(Math.max(height, min), max)}); await sleep(settle); }; diff --git a/screenshots/manifest.mjs b/screenshots/manifest.mjs index 1e11ff76..9ca7a9c7 100644 --- a/screenshots/manifest.mjs +++ b/screenshots/manifest.mjs @@ -132,7 +132,7 @@ export const SHOTS = [ {file: 'admin/person-member.png', capture: 'admin', title: 'Admin - Household member', hint: "ivy's page (maya's kid household member, added through the player's API), Access tab (?tab=access): Managed by maya and Kid under the name, Collections maya shares with them (Ivy's books, 2 books), Access given here (nothing given directly)"}, {file: 'admin/person-listening.png', capture: 'admin', title: 'Admin - Person listening', hint: 'maya\'s page, Listening tab: her listening year, In progress, Finished, Recent sessions (1440x1100)'}, {file: 'admin/devices.png', capture: 'admin', title: 'Admin - Devices', hint: 'People > Devices: every signed-in device with its app, last seen and address, and Sign out'}, - {file: 'admin/invite.png', capture: 'admin', title: 'Admin - Invite ready', hint: 'the invite card dialog after Invite someone: QR code, link, code'}, + {file: 'admin/invite.png', capture: 'admin', title: 'Admin - Invite ready', hint: 'the invite card dialog after Invite someone: the scan-with-the-app description, the QR card, Share, the reach notice for the capture\'s address (localhost: only this computer), and the invite link'}, {file: 'admin/invites.png', capture: 'admin', title: 'Admin - Invites', hint: 'People > Invites: the invite table'}, {file: 'admin/shares.png', capture: 'admin', title: 'Admin - Shares', hint: 'People > Shares: list + the selected share\'s folders and people'}, {file: 'admin/settings.png', capture: 'admin', title: 'Admin - Settings', hint: 'Server > Settings > General: the topic list (General to Import, Household among them), This server (a named server, Public address and Home address) and the top of Listening history'}, @@ -161,7 +161,7 @@ export const SHOTS = [ {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: 'web-player/find-librivox.png', capture: 'web', 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), with /server stood in for so it names an online Bridge offering librivox (the demo server has none): 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; 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'}, @@ -178,7 +178,8 @@ export const SHOTS = [ {file: 'admin/activity-live.png', capture: 'admin', title: 'Admin - Live now', hint: 'Activity > Live now: two devices playing (one transcoding) and one paused'}, {file: 'admin/activity-sessions.png', capture: 'admin', title: 'Admin - Sessions', hint: 'Activity > Sessions: the session table, newest first'}, {file: 'admin/activity-year.png', capture: 'admin', title: 'Admin - Year in listening', hint: 'Activity > Year in listening: this year\'s story and calendar (1440x1100)'}, - {file: 'server/connect-page.png', capture: 'admin', title: 'Connect page', hint: 'public connect page (invite-code box / QR)'}, + {file: 'server/connect-page.png', capture: 'admin', title: 'Connect page', hint: "the public connect page after a listener's invite link redeemed, on a desktop: Scan to pair with the QR code and its scan-with-the-app hint, Open in app and Open web player (no Admin section for a listener), and Get the AudioSilo app with the store links"}, + {file: 'server/phone-connect-invite.png', capture: 'admin', title: 'Connect page - invite on a phone', hint: "the same listener's invite link opened on an iPhone (user agent): Get the app, then come back to this link, the store links and the APK line, Open web player first, Open in app, no QR code and no Admin section"}, // ── First-run setup (capture-setup.mjs: a fresh --setup server; desktop 1440 wide, dark) ── {file: 'server/setup-wizard.png', capture: 'setup', title: 'Setup page', hint: "the --setup page: Welcome - let's set up your server, the admin username and password, Create account"}, diff --git a/sidebars-users.ts b/sidebars-users.ts index e01ad156..65613266 100644 --- a/sidebars-users.ts +++ b/sidebars-users.ts @@ -19,6 +19,7 @@ const sidebars: SidebarsConfig = { 'getting-started/install-binary', 'getting-started/first-run', 'getting-started/organizing-your-library', + 'getting-started/coming-from-audiobookshelf', 'listening/mobile-apps', 'listening/connecting', 'getting-started/remote-access', diff --git a/static/img/screenshots/admin/invite.png b/static/img/screenshots/admin/invite.png index 8cd6cb46..f2f34d49 100644 Binary files a/static/img/screenshots/admin/invite.png and b/static/img/screenshots/admin/invite.png differ diff --git a/static/img/screenshots/server/connect-page.png b/static/img/screenshots/server/connect-page.png index 7012768b..909d8e6d 100644 Binary files a/static/img/screenshots/server/connect-page.png and b/static/img/screenshots/server/connect-page.png differ diff --git a/static/img/screenshots/server/phone-connect-invite.png b/static/img/screenshots/server/phone-connect-invite.png new file mode 100644 index 00000000..b00c5df7 Binary files /dev/null and b/static/img/screenshots/server/phone-connect-invite.png differ diff --git a/static/img/screenshots/setup/abs.png b/static/img/screenshots/setup/abs.png index 52c1b178..774840d3 100644 Binary files a/static/img/screenshots/setup/abs.png and b/static/img/screenshots/setup/abs.png differ diff --git a/static/img/screenshots/setup/phone.png b/static/img/screenshots/setup/phone.png index 475420d7..cbb9800e 100644 Binary files a/static/img/screenshots/setup/phone.png and b/static/img/screenshots/setup/phone.png differ diff --git a/static/img/screenshots/web-player/connect-ready.png b/static/img/screenshots/web-player/connect-ready.png index bbbfe855..a4503468 100644 Binary files a/static/img/screenshots/web-player/connect-ready.png and b/static/img/screenshots/web-player/connect-ready.png differ diff --git a/static/img/screenshots/web-player/connect.png b/static/img/screenshots/web-player/connect.png index 623e4813..e0590d34 100644 Binary files a/static/img/screenshots/web-player/connect.png and b/static/img/screenshots/web-player/connect.png differ diff --git a/static/img/screenshots/web-player/find-librivox.png b/static/img/screenshots/web-player/find-librivox.png index b653ffe5..f923a66a 100644 Binary files a/static/img/screenshots/web-player/find-librivox.png and b/static/img/screenshots/web-player/find-librivox.png differ