diff --git a/CLAUDE.md b/CLAUDE.md index d4b7f114..57438a7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,7 @@ docs-developers/ Developer pages (architecture, server{,/api}, frontend, b sidebars-*.ts canonical page lists (hand-ordered) screenshots/ capture pipeline: manifest.mjs (source of truth), run.sh, capture-web.mjs, capture-admin.mjs, capture-setup.mjs, capture-meta.mjs, - placeholders.mjs, .cache/ (gitignored) + capture-bridge.mjs, placeholders.mjs, .cache/ (gitignored) static/img/screenshots/ generated images (committed) src/pages/index.tsx landing page; src/css/custom.css theme .github/workflows/ ci.yml (build gate), deploy.yml (GitHub Pages on main) diff --git a/docs-developers/architecture/cross-repo-contract.md b/docs-developers/architecture/cross-repo-contract.md index 98ac80ea..fa6b0364 100644 --- a/docs-developers/architecture/cross-repo-contract.md +++ b/docs-developers/architecture/cross-repo-contract.md @@ -196,7 +196,9 @@ client. **Server:** the scanner records each book's `codec` (ffprobe `codec_name`, migration `0008_book_codec`); `item`/`chapters` expose `direct_playable` via -`media.DirectPlayable`; `GET .../stream?path=…&transcode=1` pipes through ffmpeg +`media.DirectPlayable` (from 2.4.0 the container counts too: a book with an +`.mka` file is always `false`, so the web player transcodes it and a native app +can't play it); `GET .../stream?path=…&transcode=1` pipes through ffmpeg to MP3 (`media.Transcode`), with `&t=` to start mid-file (transcoded output isn't byte-seekable). All gated by the `--ffmpeg` flag and reflected in the `transcode` capability (seam 8). @@ -246,7 +248,7 @@ page captions such chapters (an older app shows them as ordinary chapters), and saved chapters with a newer server answer while its audio files are unchanged (same paths, order and sizes; `startChapterRefresh`). Podcasts additionally return `id3_chap` from the server's CHAP attachments; the player (2.2.0 and -later) captions it "Chapters from the episode file". See [podcasts](#17-podcast-shows-feeds-and-client-visibility). +later, first released in 2.4.0) captions it "Chapters from the episode file". See [podcasts](#17-podcast-shows-feeds-and-client-visibility). **A change requires:** any change to chapter normalization changes the queue builder and the timeline math with it. And always: **stream the file, not the @@ -266,7 +268,10 @@ Universal/App Links on claimed domains, else the web player) and `uri` token → device-scoped session). **Frontend:** the `audiosilo` scheme in `app.json`; the connect/pairing parser and the `/web/connect` route consume both carriers; `client.ts` -`redeemCode()`/`exchange()`. +`redeemCode()`/`exchange()`. The app's scanner and the web player's paste field also +read the console's invite link (`inviteURL`, `/connect#code=…`; `parseInviteScan` +from player 2.4.0) and sign in with its code through the same redeem and exchange, so +a change to that link's shape changes both repos. **Home and away addresses (`addresses` capability).** Every pairing payload (`/auth/redeem`, `/auth/pair`, the demo session's `pairing`) may carry @@ -826,8 +831,9 @@ security-critical: allowed and denied tests on the server. ## 17. Podcast shows, feeds and client visibility Phase 2c-2 extends the server/Bridge seam with episodes on disk, retention -and on-demand fetching/status/Retry. Phase 2d-1 ships the player's views to -players from 2.2.0; Phase 2d-2 adds discovery, follow by URL, OPML and Check +and on-demand fetching/status/Retry. Phase 2d-1 ships the player's views +(shown to players the gate admits, 2.2.0 and later; first released in the +2.4.0 player); Phase 2d-2 adds discovery, follow by URL, OPML and Check for new episodes to the player. See [Podcasts](../bridge/podcasts.md) and the [API reference](../server/api/reference.md#podcasts) for exact fields. diff --git a/docs-developers/bridge/configuration.md b/docs-developers/bridge/configuration.md index 8422002e..d1a3b64b 100644 --- a/docs-developers/bridge/configuration.md +++ b/docs-developers/bridge/configuration.md @@ -200,7 +200,16 @@ for example, `openssl rand -hex 32`. The startup log says where the key came fro The page's own password is not a secret in this sense: it is hashed (argon2id) in the database, generated and **printed once** to the log on the first start, and -changed on the page's Settings tab. +changed on the page's Settings tab. An owner who lost it runs +`audiosilo-bridge reset-password [--data DIR]` (from v0.3.0; in Docker +`docker exec -u PUID:PGID audiosilo-bridge reset-password`): it +prints a new generated password once and signs every session out, changing +only the password hash and the sessions (`app.ResetPassword`), so the pairing, +the mappings, the jobs and the sealed secrets stay, and a running Bridge takes +it at once. It refuses a folder with no Bridge database (a mistyped `--data`) +and, as root, a data folder another user owns (SQLite's new files would be +root's), naming the user to run it as. The command goes before its flags: +`--data DIR reset-password` is refused rather than starting the Bridge. ## PUID and PGID @@ -222,8 +231,9 @@ user's. The entrypoint warns when it isn't writable. ## The images -`ghcr.io/kodestar/audiosilo-bridge` (private while the repository is), built from -one Dockerfile with four targets: +`ghcr.io/kodestar/audiosilo-bridge`, public from v0.3.0 (pulled with no login; +the source repository stays private), built from one Dockerfile with four +targets: | Tag | Target | What's in it | Arch | |---|---|---|---| diff --git a/docs-developers/bridge/development.md b/docs-developers/bridge/development.md index 9d91651f..c1f62f4b 100644 --- a/docs-developers/bridge/development.md +++ b/docs-developers/bridge/development.md @@ -5,7 +5,8 @@ description: "Working in audiosilo-bridge: the gate and CI, the package layout a `audiosilo-bridge` is a Go module (`github.com/kodestar/audiosilo-bridge`, Go 1.26, AGPL-3.0) with a small web page in `web/` (Vite, React 19, TypeScript, Tailwind v4, -Node 24). It is **private**; its own `CLAUDE.md` and `PACKAGES.md` (the +Node 24). The repository is **private** (its images are public from v0.3.0); +its own `CLAUDE.md` and `PACKAGES.md` (the per-package rules and history) are authoritative, and the seam with the server is section 23 of the workspace `CROSS-REPO.md`. @@ -31,15 +32,17 @@ docker build --target slim . # a Dockerfile change: slim | cuda | full | agent CI (`.github/workflows/ci.yml`) runs both sides on every pull request and every push to `main`: a **go** job (build, vet, `go test -race`, golangci-lint) and a **web** job (`npm ci`, typecheck, lint, the prettier check, the tests and a -production build). `image.yml` builds and pushes the three images on `v*` tags and -on manual dispatch (`full` and `slim` for amd64 and arm64, `cuda` for amd64); which -tags each publishes is in [the images](configuration.md#the-images). +production build). `image.yml` builds and pushes the four images to the public +GHCR package on `v*` tags and on manual dispatch (`full`, `slim` and `agent` for +amd64 and arm64, `cuda` for amd64); which tags each publishes is in +[the images](configuration.md#the-images). To run it locally: ```sh scripts/build-web.sh # build the page and embed it into bin/audiosilo-bridge ./bin/audiosilo-bridge --data ./data # the first start prints the page's password ONCE +./bin/audiosilo-bridge reset-password --data ./data # lost it: a new one, printed once ``` Then open `http://localhost:8091`, sign in, and pair it with a local server (see @@ -129,7 +132,8 @@ Conventions that carry the design: sends on every call, `AudioSilo Bridge/ ()`, must parse with the server's `auth.ParseClient`; a test holds the server's pattern. - **Secrets are never logged or echoed.** The page's password is printed once at the - first start (to standard error, never through the logger), the server token, the + first start (to standard error, never through the logger) and once by + `reset-password` (to standard output), the server token, the GitHub sign-in and the Accounts tab's agent credentials are sealed (the page API says only whether one is set), and the log ring redacts attributes whose key looks like a secret (`token`, `password`, `code`, `key`, `secret`, `cookie`, `authorization`). diff --git a/docs-developers/bridge/index.md b/docs-developers/bridge/index.md index 1651bb0d..528dabda 100644 --- a/docs-developers/bridge/index.md +++ b/docs-developers/bridge/index.md @@ -9,9 +9,14 @@ stay read-only and safe to expose to the internet. It is a separate program in i own repository, `audiosilo-bridge`, and runs beside the server, usually as a second container. -:::info[Private repository, built in phases] -The `audiosilo-bridge` repository (and its images, `ghcr.io/kodestar/audiosilo-bridge`) -is **private** for now, and the Bridge is being built in phases. **Phase 0** laid +:::info[Private repository, public images, built in phases] +The `audiosilo-bridge` repository is **private**; its images, +`ghcr.io/kodestar/audiosilo-bridge`, are **public from v0.3.0** and pull with no +login. Bridge v0.3.0 is the release that speaks to server 2.4.0 (podcasts, +contributions, generating and detecting chapters): run v0.3.0 or later with a +2.4.0 server. Nothing checks the version: the server offers each feature by the +job kinds the Bridge's heartbeat reports, so an older Bridge pairs but lacks +them. The Bridge is being built in phases. **Phase 0** laid the foundations: pairing, the heartbeat and the capability, the job queue with leases, the library mapping checks and one proof job, `bridge.verify`. **Phase 1a** added the first job that makes it useful, @@ -38,7 +43,7 @@ fetching on play, fetch status/Retry, one-ahead queue preparation and a reserved download slot for play requests. **Phase 2c-3** adds server-side podcast [directory search and OPML import/export](podcasts.md#discovery-and-opml-transfer) with no Bridge change. **Phase 2d-1** adds the player's -[podcast views](podcasts.md#player-phase-2d-1) (players from 2.2.0), again +[podcast views](podcasts.md#player-phase-2d-1) (first released in player 2.4.0), again with no Bridge change, and **Phase 2d-2** its [discovery, follow by URL and OPML screens](podcasts.md#player-discovery-and-opml-phase-2d-2) (a CORS change on the server only). Read-along, Audible backups and file moves diff --git a/docs-developers/bridge/pairing-and-jobs.md b/docs-developers/bridge/pairing-and-jobs.md index 69512c1e..3c930217 100644 --- a/docs-developers/bridge/pairing-and-jobs.md +++ b/docs-developers/bridge/pairing-and-jobs.md @@ -279,7 +279,10 @@ handlers at priority at least 100 (`InteractiveHandler`, `podcast.fetch` today), never background work. Ordinary slots can take interactive jobs too; total enclosure concurrency stays at two. Without negotiated support, `interactive` becomes zero and `download` two, so an older server ignoring an -unknown query cannot fill the reserve with background jobs. +unknown query cannot fill the reserve with background jobs. The lanes the page +lists (`Runner.Lanes`, in `GET /api/status`) leave out a lane with no capacity +and nothing running, so that folded `interactive` lane no longer shows as +"0 of 0" (v0.3.0). A change to eligible kinds, pairing, negotiated reserve or capacities cancels and joins the outstanding claim, then reissues immediately without cancellation @@ -348,6 +351,14 @@ they were: reported again, every 30 seconds, until the server takes it or says it has moved on. - A running job of a kind the Bridge no longer runs is failed. +- **A parked job** (`needs_attention`) resumes parked, and its first report + sends the progress it last recorded, park code and all (v0.3.0). While it + stays parked, a report whose progress names no park keeps the one before it + (`Reporter.Progress`), since a resumed job reports its first steps before it + hears its engine's park again. The server keeps the stored park too when an + update to a parked job names none (`carryPark` in `internal/bridge`, server + 2.4.0), so a restart no longer reads as a park dropped and a new one raised: + no spurious `bridge_job_failed` events, and the console keeps the reason. When the server refuses the token (a `401`), a running job stops where it is, without a final report, and is kept aside. When a link to the same server works diff --git a/docs-developers/bridge/podcasts.md b/docs-developers/bridge/podcasts.md index 3cc08adc..9f700095 100644 --- a/docs-developers/bridge/podcasts.md +++ b/docs-developers/bridge/podcasts.md @@ -87,6 +87,15 @@ Top-level fields: | `subscribed_at`, `updated_at` | RFC 3339 UTC timestamps | | `show`, `episodes` | show object and episodes, newest first | +**Episode order** (`podcast.newestFirst`): the newest publication date first, +then, for episodes sharing a date or with none (a LibriVox serial), the higher +season (none counts as 0), then the higher episode number (a numbered episode +before an unnumbered one), then the path. So an undated serial lists from its +last numbered episode back to E1, not in the order of its hashed file names +(v0.3.0; before, date then path). The server's episode lists, `/next` and the +retention windows use the same key +([episode order](../server/api/reference.md#podcast-read-routes)). + `show` contains `title` (300 bytes), `author` (300), `description` (4,000), `link` (`""` when private), `language` (35), `type` (`episodic` or `serial`), `explicit`, `categories` (up to ten), `podcast_guid`, and nullable `image`. @@ -440,7 +449,10 @@ poller. The rules it keeps: - **Polling.** While `queued` or `fetching`, it GETs the status every `max(2, Retry-After)` seconds and seeds `['podcastFetch', ...]` from every answer (the 503's `fetch`, the POST's, each GET's). A network error, a `429` - or a `5xx` while polling reschedules the next turn at 2 seconds. + or a `5xx` while polling reschedules the next turn with a backoff + (`blipDelay`: 2 seconds, doubling with each failed look in a row up to 60, + never under the answer's `Retry-After`; an answer resets it), so a server + answering 5xx for good is not asked every 2 seconds. - **Background.** It pauses while the app is not active and asks once at once on the way back (`onForeground`; RN-web maps it to `visibilitychange`). - **It never POSTs by itself.** It stops at `failed`, `unavailable`, or @@ -465,7 +477,10 @@ downloads store refuses `book.episode` as `unsupported`, the download control and the tile menu leave it out, and auto-download and keep-ahead skip it (an absent episode would answer 503 to the native download engines, and nothing would clean the files up). The Cast button hides for an episode (cast tokens -never see podcasts; the server answers `403` to `POST /cast/token` for one). +never see podcasts; the server answers `403` to `POST /cast/token` for one, +with `error.code: "not_a_book"` from 2.4.0), and starting an episode while a +book is cast is refused before any token is asked for (`CastBlockedError` +`episode`; a `not_a_book` refusal reads the same). An episode never opens the end credits. Kids mode lists no podcast library (decision 6.2). Golden: the player keeps `wire-2b.json` byte-identical in `src/testing/podcast-fixtures/`. @@ -509,8 +524,9 @@ none); the routes, shapes and error codes are in the ### Player: discovery and OPML (Phase 2d-2) -Players from 2.2.0 search a directory, follow a feed by its address, import -and export OPML lists and ask a followed show to check for new episodes. The +The player (first released with these screens in 2.4.0) searches a +directory, follows a feed by its address, imports and exports OPML lists and +asks a followed show to check for new episodes. The screens are in [Frontend: Podcasts](../frontend/podcasts.md#finding-shows); this section is the wire mirror and the rules the player keeps. The only server change is CORS: an allowed origin may read `Retry-After` and the @@ -635,7 +651,12 @@ OPML codes (`opml_rate_limited`, `opml_busy`, `opml_limit`, `invalid_opml`, framework-free with its own timers; `useOpmlImport` keeps it, and the picked bytes, for the Find shows page's life). A file over 1 MiB is refused before any upload. Each upload's rows merge by `outline_index` (the latest - answer wins). No `limited` row left: `done`. Otherwise the next upload of + answer wins, except that a row this import followed or queued stays + followed when a later wave answers it `already_following`). No `limited` + row left: `done`, and no other wave is sent (what is queued is followed + live). `error` rows never start a wave either: a server from 2.4.0 answers + a feed whose follow failed within the hour `recently_failed` and queues + nothing, so a few dead feeds no longer take every wave's slots. Otherwise the next upload of the same bytes waits until every request this import queued (`queued` and `already_queued` rows' `request_id`) is no longer open in `GET /me/podcasts` (absent counts as ended; read every 5 s), fewer than five @@ -643,7 +664,16 @@ OPML codes (`opml_rate_limited`, `opml_busy`, `opml_limit`, `invalid_opml`, upload. `unavailable` rows never start a wave: a `done` import with some left offers a manual Try again (`canRetryImport`), which uploads once more. The panel reads each queued row's request live, so the import reads done - ("All done.") only once none is still being added. A wave that moves no row out + ("All done.") only once none is still being added and none failed. **A row + that could not be added** (`error`, `recently_failed` included, or one whose + request ended `cancelled`) has its own **Try again**: a single + `POST /podcasts/follow` by address, which the server always tries again. + The server reports only a redacted label, so the address comes from the + picked bytes (`outlineFeeds` in `opml-feeds.ts`, counting outlines as the + server's parser does) and is used only when its scheme and host match the + row's label (`rowFeedUrl`); a row whose address can't be found has no Try + again. Its answer stands over the waves' answer for that row + (`withRetries`). A wave that moves no row out of `limited` stops as `stalled` (Try again). `429 opml_rate_limited` and `503 opml_busy` wait their `Retry-After` (else 30 s and 2 s); five such answers in a row stop it. Any other refusal stops as `failed` with its code, @@ -664,8 +694,13 @@ OPML codes (`opml_rate_limited`, `opml_busy`, `opml_limit`, `invalid_opml`, refusals read through `find-shows-model.ts`, and a failed request's `error.code` through `requestReasonKey` (`podcast-model.ts`: `dns`, `timeout`, `tls`, `network` could not reach the feed; `not_a_feed`, - `not_a_podcast`; `gone` and `http_*`; `request_expired` and - `feed_secret_missing` ask to follow again), all six locales. + `not_a_podcast`; `gone` (410), `http_404` (nothing at that address), + `http_401`/`http_403` (refused) and other `http_*`; `blocked_address` (a + local network the server may not reach); `too_large`; `request_expired` and + `feed_secret_missing` ask to follow again), all six locales. An import row's + reason (`importRowReasonKey`) is its request's when the requests list it, + else its own code's (`recently_failed`: it failed within the last hour, + follow it on its own to try again). **Goldens.** The player keeps the server's `internal/podcast/testdata/phase2c3/` files it reads byte-identical in @@ -685,11 +720,11 @@ All keys are required on PUT, including unused ones: `mode` is Policies are durable server state keyed by library/path/show id, not feed data. A replacement show at the same path starts with the default. -- Keep downloads the newest N currently listed entries, publication descending - then path descending, undated last. Deletion additionally requires being - outside this window. -- Archive downloads every currently listed entry oldest first, path ascending, - undated last, and never deletes. +- Keep downloads the newest N currently listed entries in the episode order + above (publication, season, episode number, path, descending), undated last. + Deletion additionally requires being outside this window. +- Archive downloads every currently listed entry oldest first (the same key + ascending), undated last, and never deletes. - On demand fetches on play and permits one-ahead queue preparation, with no feed-wide scheduled downloads. Individual admin fetches work too. Its deletion rule has no Keep window. @@ -744,6 +779,11 @@ the same duration. Later equal hashes retain that warning. Saved progress, finished state, bookmarks, annotations and queues are never rescaled or reset. Per-episode process/filesystem locks prevent overlapping reclaimed writers. +The filesystem lock is the episode's `.fetch.lock`; its holder removes it while +still holding it, as it lets go (`releaseLock`, v0.3.0; before, the files +stayed), and a waiter that then gets a lock on a file no longer at that path +opens the path again (`lockedAt`), so the files never pile up and no two jobs +hold one. The short show mutation lock is shared by subscribe/poll/fetch/retain and never spans an audio GET. A writer recovers journals, confirms its lease, checks mapping and identities, and rereads the latest feed before committing, keeping @@ -912,7 +952,9 @@ of at least 24 URL-safe characters containing both a letter and a digit. `private:true` forces privacy; `false` cannot force a private URL public. The server holds a private subscribe URL only in memory from follow until the -job ends. Stored params have `feed_url: ""`; the URL is injected only into the +job ends; deleting the library drops the URLs held for its jobs at once +(`Service.PruneSecrets`, server 2.4.0; before, they stayed until a restart). +Stored params have `feed_url: ""`; the URL is injected only into the claim answer. A restart before claim fails it as `request_expired`: follow again. The Bridge seals the URL **before** recording its local claim, under `podcast_job__`, then keeps the show's URL as diff --git a/docs-developers/contributing/documentation.md b/docs-developers/contributing/documentation.md index 1bd931e3..f6c96306 100644 --- a/docs-developers/contributing/documentation.md +++ b/docs-developers/contributing/documentation.md @@ -91,7 +91,7 @@ mappings: | Scanner, detection, metadata | [server/scanner.md](../server/scanner.md) + `/users/getting-started/organizing-your-library` | | Community chapters (`internal/chapteralign`, `internal/chaptercheck`, `catalog/communitychapters.go`, the Chapters card's panel) | [server/community-chapters.md](../server/community-chapters.md) + the Community chapters section of `/users/admin/books` (and `/users/admin/health` for the Health rows) | | Auth, invites, shares | [server/auth-and-security.md](../server/auth-and-security.md) + `/users/admin/users-and-invites`, `/users/admin/sharing` | -| The Bridge: the server's side (`auth/bridge.go`, `internal/bridge`, `handlers_bridge.go`, `handlers_bridge_estimate.go`, the `bridge` capability, the console's `features/bridge/`) or `audiosilo-bridge` (its config, images, compose example, mapping checks, job runner, page, the agent container and its credential hand-off, `sidecars.generate`, `sidecars.describe`, `sidecars.contribute`, `meta.correct`, the setup checklist, the Accounts tab and its GitHub sign-in) | [the Bridge developer pages](../bridge/index.md) (index, pairing and jobs, mappings, characters and recaps, the .audiosilo folder, the agent container, contributions, configuration, development) + the [reference](../server/api/reference.md#the-bridge) + [api/index.md](../server/api/index.md#capability-flags---gate-your-features) (the capability, the codes, the bridge token) + [auth-and-security.md](../server/auth-and-security.md#bridge-tokens) + [data-model.md](../server/data-model.md#the-bridge) + [backups-and-notifications.md](../server/backups-and-notifications.md#events) (the `bridge_job_*` events) + [web-ui.md](../server/web-ui.md) (the console's Settings > Bridge and the book page's Bridge parts) + the [cross-repo contract](../architecture/cross-repo-contract.md#16-the-bridge) (after the workspace `CROSS-REPO.md` §23) + `/users/admin/bridge`, `/users/admin/generating-characters-and-recaps`, `/users/getting-started/running-a-bridge` (its steps follow the setup checklist's order), `/users/getting-started/bridge-accounts` and `/users/community/contributing-from-your-server` + [meta/contributing-data.md](../meta/contributing-data.md#issues-filed-through-the-audiosilo-app) (what the Bridge files, and how meta routes it) + the `admin/settings-bridge*.png`, `admin/bridge-setup.png`, `admin/bridge-pair.png`, `admin/book-generate.png`, `admin/generate-dialog.png` and `admin/book-description.png` screenshots | +| The Bridge: the server's side (`auth/bridge.go`, `internal/bridge`, `handlers_bridge.go`, `handlers_bridge_estimate.go`, the `bridge` capability, the console's `features/bridge/`) or `audiosilo-bridge` (its config, images, compose example, mapping checks, job runner, page, the agent container and its credential hand-off, `sidecars.generate`, `sidecars.describe`, `sidecars.contribute`, `meta.correct`, the setup checklist, the Accounts tab and its GitHub sign-in) | [the Bridge developer pages](../bridge/index.md) (index, pairing and jobs, mappings, characters and recaps, the .audiosilo folder, the agent container, contributions, configuration, development) + the [reference](../server/api/reference.md#the-bridge) + [api/index.md](../server/api/index.md#capability-flags---gate-your-features) (the capability, the codes, the bridge token) + [auth-and-security.md](../server/auth-and-security.md#bridge-tokens) + [data-model.md](../server/data-model.md#the-bridge) + [backups-and-notifications.md](../server/backups-and-notifications.md#events) (the `bridge_job_*` events) + [web-ui.md](../server/web-ui.md) (the console's Settings > Bridge and the book page's Bridge parts) + the [cross-repo contract](../architecture/cross-repo-contract.md#16-the-bridge) (after the workspace `CROSS-REPO.md` §23) + `/users/admin/bridge`, `/users/admin/generating-characters-and-recaps`, `/users/getting-started/running-a-bridge` (its steps follow the setup checklist's order), `/users/getting-started/bridge-accounts` and `/users/community/contributing-from-your-server` + [meta/contributing-data.md](../meta/contributing-data.md#issues-filed-through-the-audiosilo-app) (what the Bridge files, and how meta routes it) + the `admin/settings-bridge*.png`, `admin/bridge-setup.png`, `admin/bridge-pair.png`, `admin/book-generate.png`, `admin/generate-dialog.png` and `admin/book-description.png` screenshots + the Bridge page's `bridge/*.png` screenshots (`capture-bridge.mjs`: `BRIDGE= SHOTS_ONLY=bridge/ screenshots/run.sh`) | | Podcasts: library kind, `internal/podcast`, catalog/handlers, the Bridge's `podcastjob`, `netguard`, feed/Fetch files, downloads, on-demand/status/Retry and queue prefetch, listener fetch limits, claim priority/reserved capacity, retention aggregates/policies, generations/ETags, publication journals, CHAP or root markers; directory search and OPML (`internal/podcast/{discovery,opml,quota}`, `follow.go`, `opmlimport.go`, `export.go`, `podcasts.discovery.enabled`, the Podcast Index variables) | [bridge/podcasts.md](../bridge/podcasts.md) (with its [discovery and OPML](../bridge/podcasts.md#discovery-and-opml-transfer) section, its [player](../bridge/podcasts.md#player-phase-2d-1) and [player discovery and OPML](../bridge/podcasts.md#player-discovery-and-opml-phase-2d-2) sections) + [server/modules.md](../server/modules.md), [configuration.md](../server/configuration.md#managed-modules-modules) (and its [podcast discovery](../server/configuration.md#podcast-discovery-podcastsdiscovery) section), [API reference](../server/api/reference.md#podcasts) (including admin storage and Bridge retention), [API flags](../server/api/index.md#capability-flags---gate-your-features), [data-model.md](../server/data-model.md#podcasts), [scanner.md](../server/scanner.md#podcast-libraries), [auth-and-security.md](../server/auth-and-security.md#podcast-visibility), [bridge/configuration.md](../bridge/configuration.md#podcasts), [attachments.md](../bridge/attachments.md#podcast-chap-attachments), [mappings.md](../bridge/mappings.md#podcast-libraries-the-marker), [pairing-and-jobs.md](../bridge/pairing-and-jobs.md#podcast-jobs), [bridge/index.md](../bridge/index.md), [cross-repo-contract.md](../architecture/cross-repo-contract.md#17-podcast-shows-feeds-and-client-visibility) + `/users/admin/podcasts`, `/users/admin/libraries`, `/users/admin/bridge`, `/users/admin/server#modules`, `/users/getting-started/running-a-bridge`, `/users/faq` (what a directory search sends) + `admin/libraries.png`, `admin/library-add.png`, `admin/library-add-podcasts.png`, `admin/library-edit.png` and `admin/settings-modules.png` | | The player's podcasts: `src/components/podcasts/`, `src/playback/episode-fetch.ts`, Home's New episodes (`new-episodes-*`, `use-new-episodes.ts`), the `shows` Library mode, `src/lib/library-kind.ts`, the podcast client gate (`auth.PodcastPlayerMinVersion`, `app.json` `expo.version`, `client-id.ts`), the gated whole-list replace (`catalog.GatedScopes`); finding shows (`find-shows-*`, `directory-search.tsx`, `discovery-*`, `follow-by-url.tsx`, `follow-requests.tsx`, `opml-*`, `use-opml-import.ts`, `shows-actions.tsx`, `check-episodes-button.tsx`, `artwork-retry.ts`, `src/lib/podcast-requests.ts`, `findShowsHref`, the `podcast_discovery` / `podcast_opml` client methods and hooks), the server's CORS lists (`corsAllowHeaders`, `corsExposeHeaders`) | [frontend/podcasts.md](../frontend/podcasts.md) (and its [Finding shows](../frontend/podcasts.md#finding-shows)) + [bridge/podcasts.md](../bridge/podcasts.md#player-phase-2d-1) (the wire and the controller's rules; [discovery and OPML](../bridge/podcasts.md#player-discovery-and-opml-phase-2d-2) for 2d-2) + [api/index.md CORS](../server/api/index.md#cors) and [cross-repo-contract.md §12](../architecture/cross-repo-contract.md#12-cors-for-web-development) + [auth-and-security.md](../server/auth-and-security.md#podcast-visibility) + the [reference](../server/api/reference.md#podcasts) (the gate, the replace rules) + [api/index.md](../server/api/index.md#client-identification-x-audiosilo-client) + the lines in [playback.md](../frontend/playback.md), [state-and-data.md](../frontend/state-and-data.md), [offline.md](../frontend/offline.md), [chromecast.md](../frontend/chromecast.md), [end-of-book.md](../frontend/end-of-book.md) and [kids-mode.md](../frontend/kids-mode.md) + `/users/listening/podcasts` (and the podcast lines in `browsing`, `up-next`, `end-of-book`, `offline-downloads`, `chromecast`, `kids-mode`, `mobile-apps`, `/users/troubleshooting`, `/users/admin/podcasts`, `/users/admin/server#modules`, `/users/faq`) + the `web-player/library-shows.png`, `podcast-show.png`, `home-new-episodes.png`, `episode-fetching.png`, `phone-podcast-show.png`, `find-shows-search.png`, `find-shows-address.png`, `find-shows-import.png` and `phone-find-shows.png` screenshots (the podcast seed in `run.sh` and its `directory` fixture; Find shows' stand-in routes in `capture-web.mjs`) | | Managed modules: the server's `internal/modules` (its `Runner`, the `builtin` registry), `api/modules.go`, a `modules.` setting, the console's Settings > Modules (`features/settings/modules-topic.tsx`), the player's `src/modules/` registry and slots | [server/modules.md](../server/modules.md) (+ the adding-a-module steps) + [configuration.md](../server/configuration.md#managed-modules-modules) (the key, the variable, the runtime table) + the [reference](../server/api/reference.md#get-apiv1adminmodules) (`GET /admin/modules`, the settings envelope's `modules`, the module's flag on `GET /server`, the module's Bridge jobs) + [api/index.md](../server/api/index.md#capability-flags---gate-your-features) (the flag, `module_off`, `kid_account`, `demo_account`) + [bridge/pairing-and-jobs.md](../bridge/pairing-and-jobs.md#jobs-of-a-managed-module) (the jobs a module owns, held while it is off) + [web-ui.md](../server/web-ui.md) + [frontend/state-and-data.md](../frontend/state-and-data.md#player-modules-srcmodules) + the [cross-repo contract](../architecture/cross-repo-contract.md#8-capability-flags-get-apiv1server) + `/users/admin/server#modules` (the console's Learn more link opens it: keep the heading) + `/users/admin/bridge#when-the-contributions-module-is-off` + the `admin/settings-modules.png` screenshot | @@ -116,8 +116,10 @@ the feature name - several pages deliberately mark these as not-yet-shipped. Every embedded image is listed in `screenshots/manifest.mjs` and lives under `static/img/screenshots/`. The pipeline (`screenshots/run.sh`) rebuilds the server, seeds a small public-domain LibriVox library, runs a demo-mode server, -and captures the web player, admin console, connect page, and the first-run -setup page and console flow (on a second, `--setup` server) with Playwright. +and captures the web player, admin console, connect page, the first-run +setup page and console flow (on a second, `--setup` server) and, when the private +`audiosilo-bridge` checkout is there, the Bridge's own page (a real Bridge paired +with the demo server, `capture-bridge.mjs`) with Playwright. Anything it can't reach gets a labelled placeholder so the build never breaks. Rules that keep this maintainable: diff --git a/docs-developers/contributing/releasing.md b/docs-developers/contributing/releasing.md index 7e17b868..069ea224 100644 --- a/docs-developers/contributing/releasing.md +++ b/docs-developers/contributing/releasing.md @@ -187,6 +187,24 @@ meta repo at the workspace root). The honest summary: release (Play Console > Advanced settings > Form factors), and Google reviews such releases against its car app quality guidelines (a failure blocks open testing and production). +- **Android release builds are minified and resource-shrunk with R8**, enabled by + the `expo-build-properties` plugin entry in `app.json` (`enableMinifyInReleaseBuilds`, + `enableShrinkResourcesInReleaseBuilds`). Debug builds and jest never run R8, so a + new or upgraded native dependency needs + [an Android release build](../frontend/testing.md#an-android-release-build) before + it ships. A release-only `ClassNotFoundException`, `NoSuchMethodError` or + `NoSuchFieldError` (or a field read by reflection arriving empty) means R8 stripped + code: add a keep rule to the same plugin entry's `android.extraProguardRules`, one + comment per rule saying what it keeps; never turn minify off. A release-only + `Resources$NotFoundException` is the resource shrinker instead, which a ProGuard + rule can't keep: the resource needs a `tools:keep` list in its own `res/raw/*.xml`, + written by a config plugin (React Native already generates `raw/keep.xml`, so not + that name). R8's mapping file ships inside the `.aab`, so the Play Console + deobfuscates crash traces without a separate upload. A `preview` build (internal + distribution) is an APK whose mapping is kept nowhere: to trace one, build it with + `EAS_LOCAL_BUILD_SKIP_CLEANUP=1 eas build --local` and keep + `android/app/build/outputs/mapping/release/mapping.txt` from the working directory + it leaves behind. - **The project is on the EAS free tier**, so the preferred path is **`eas build --local`** - it compiles on your own machine (no build-quota usage, no cloud queue) while still using EAS-managed signing credentials and diff --git a/docs-developers/frontend/chromecast.md b/docs-developers/frontend/chromecast.md index 9d9cc791..fe494e98 100644 --- a/docs-developers/frontend/chromecast.md +++ b/docs-developers/frontend/chromecast.md @@ -110,8 +110,9 @@ The pure rules are `cast-media.ts`: - **`castBlockReason`** - why a book can't be cast: `unsupported` (no `cast` flag), `local-address` (the address is this device: `localhost`, `*.localhost`, the loopback range, `::1`, `0.0.0.0`; `isReachableByReceiver`), `codec` (it needs the - transcoder and the server has none), `no-connection` (its server isn't signed in). - The Cast button and the cast engine share it. + transcoder and the server has none), `no-connection` (its server isn't signed in), + `episode` (a podcast episode, which never casts). The Cast button and the cast + engine share it. - **`castNeedsTranscode`** - the server's `direct_playable: false`, or a codec the Default Media Receiver doesn't play (`CAST_UNPLAYABLE_CODECS`: ALAC, the AC-3 family, DTS, TrueHD, WMA, AMR). Such a book casts with `?transcode=1` when the @@ -169,7 +170,13 @@ the playing book's server has `cast` (an older server, or flags not known yet: n button); while a session is connected it shows whatever the book, so a cast can always be stopped. It is not offered for a podcast episode (`episode` in the model, from `NowPlaying.episode`): a cast token never covers podcasts, and the -server answers `403` to `POST /cast/token` for one. Native renders the SDK's own `CastButton` in a 44 pt frame; the +server answers `403 not_a_book` to `POST /cast/token` for one. An episode +started while a book is cast is refused before any token is asked: +`prepareCast` blocks it as `episode` when its item says so (`book.episode`) or +its library is a podcast library (`cachedPodcastLibrary`), and reads a +`not_a_book` refusal of the token the same way. The cast session then ends and +a toast says "This episode can't be cast" ("Podcast episodes can't be cast yet, +so this one stays on this device."). Native renders the SDK's own `CastButton` in a 44 pt frame; the web draws our own, which opens Chrome's Cast chooser. When `castBlockReason` blocks the book, the button explains why instead (a toast) rather than casting. Strings are in the `cast` namespace. diff --git a/docs-developers/frontend/connect-and-addresses.md b/docs-developers/frontend/connect-and-addresses.md index 690a1319..48376036 100644 --- a/docs-developers/frontend/connect-and-addresses.md +++ b/docs-developers/frontend/connect-and-addresses.md @@ -24,7 +24,18 @@ route table is in the [overview](overview.md#route-map). Three steps, counted by `looksLikeHomeAddress` (the same host rule the server uses, see [`lan_url`](../server/configuration.md#home-address-lan_url)); or the server's error. Native offers **Scan a QR code** (`/connect/scan`, `expo-camera`); web offers **I have - a pairing link** (pasted, parsed with `parsePairingScan`). A route `token` (a pairing + a pairing link** (pasted, parsed with `parsePairingScan`). Both also take the + console's **invite link**, `https:///connect#code=XXXX-XXXX-XXXX-XXXX` + (`parseInviteScan` in `src/lib/pairing.ts`, the server's `inviteURL`; a + `/web/connect` link is a pairing link, never an invite): it sets the pending server + and opens the sign-in step, which fills the code in and signs in with it at once, + once (`initialCode`). The code is handed over in memory (`holdInvite` / + `takeInviteCode` in `invite-handoff.ts`, taken once and only for that server), + never in the route, where it would stay in the browser's history. On the served + player, a pasted pairing or invite link for **another server** sends nothing (its + CSP would block the call, which would read as "Could not reach the server"): it + says the link is for another server and offers **Open the link** (`Linking.openURL`) + instead. A route `token` (a pairing link or QR, with `server` on native and `home`/`away` when the server sent them) is exchanged at once. Remembered servers the person listening isn't signed in to (`knownToOffer` over `useProfileConnections`, from `src/lib/known-servers.ts`; for @@ -40,7 +51,8 @@ route table is in the [overview](overview.md#route-map). Three steps, counted by is hidden while every connection is at the page's origin (`onlyOwnServer`), and the sign-in step's "Another server" reads "Back" and goes back. 2. **Sign in** (`/connect/sign-in` → `SignInStep`): an invite code (`redeemCode` then - `exchange`) or a username and password (`login`), against the address in + `exchange`; one handed over by an invite link is submitted at once, and a refused one + stays in the field with the reason) or a username and password (`login`), against the address in `pendingServerUrl`, read once when the screen opens. It uses a bare client: it is not a saved connection yet, and a wrong password's `401` must never flag a reconnect. It shows the "At home and away" card only when the device already knows both addresses diff --git a/docs-developers/frontend/end-of-book.md b/docs-developers/frontend/end-of-book.md index a3e8c221..23067bbb 100644 --- a/docs-developers/frontend/end-of-book.md +++ b/docs-developers/frontend/end-of-book.md @@ -126,16 +126,22 @@ else the folder), so the book kept ready is usually the book that plays; it diff in skipping finished series books too and in planning nothing when `/next` fails ([Offline](offline.md#keep-the-next-books-ready-keep-aheadts--keep-ahead-controllerts)). -**Leaving the queue** is `dropFromQueue(connectionId, books)` (`end-of-book.ts`, +**Leaving the queue** is `dropFromQueue(connectionId, books)` (`queue-drop.ts`, framework-free): on a server the cache knows has `queue`, it looks the books up in that connection's queue (the cached one, else one read), removes each by the stored entry's own path and takes it out of the cached queue at once (so a second call for the same book, the credits opened on return after the end already dropped it, sends -nothing), and is quiet - housekeeping never toasts or reaches the reachability -tracker. **The finished book always leaves Up next where it is finished**, whether +nothing), sends one remove even when two callers drop the same entry at once (a +start in place and the end of a book), and is quiet - housekeeping never toasts or +reaches the reachability tracker. **The finished book always leaves Up next where it is finished**, whether or not the next one starts (the credits opened with `ended`, a Play now within the end tolerance, `BookEndedListener` in the background or with the credits already open); the queue -entry that plays leaves it inside `advanceTo`, only once it has started. +entry that plays leaves it inside `advanceTo`, only once it has started. **Any +start leaves Up next too** (2.4.0): `startBookInPlace` (a book's page, a browse row, +the car), the `/player` route's auto-start and Up next's Play now all call +`dropStartedBook` (`drop-started.ts`), which drops the book only once it is the book +loaded and not in error, so a start that failed or that another start took over +keeps its place in the queue. The resolver's reads (`up-next-sources.ts`: the capability read, the queue, the finished set, `/next`) go through `fetchFailFast` (`api/hooks.ts`): TanStack holds a diff --git a/docs-developers/frontend/overview.md b/docs-developers/frontend/overview.md index 9b6a502a..a12b879d 100644 --- a/docs-developers/frontend/overview.md +++ b/docs-developers/frontend/overview.md @@ -32,7 +32,8 @@ content by `(library_id, rel_path)`, and owns exactly one hard problem: ``` src/app/ Expo Router routes: (app) with its five tab groups, connect/ onboarding, the player + finished modals, demo landing, - +html.tsx web shell, +native-intent.tsx (app links as paths) + +html.tsx web shell, +native-intent.tsx (app links as paths), + +not-found.tsx (any unknown path) src/api/ client.ts (typed fetch wrapper), types.ts (wire mirrors), hooks.ts (React Query), provider.tsx (multi-connection registry), reachability.ts (online/offline tracking), address-route.ts + @@ -140,6 +141,7 @@ URLs, so every URL below is the same as before the groups existed. | `/browse?type=recent\|finished` | `(app)/(home,library,search,offline,me)/browse.tsx` → `src/components/library/books/see-all-screen.tsx` | The "See all" cover grid behind Home's Recently finished shelf (Recently added's See all opens the Library's Books mode with `sort=recent` instead). | | `/search` | `(app)/(search)/search.tsx` → `src/components/search/search-screen.tsx` | Search across the person listening's connections: books (de-duplicated), series, people and met characters, in groups with their own loading/error states (`useSearch`, shared with the palette; see [Search](state-and-data.md#search-and-the-spoiler-model)). Empty, it shows the recent searches and Browse cards. The query lives in `useSearchStore`, so it survives the screen remounting. On a **native tablet** the top bar's search field jumps here and bumps the store's `focusRequest`, which re-keys the input so it takes focus; on web the same field opens the [command palette](#command-palette-web) instead. Leaving the Search tab clears the query (`useShellEffects`). | | `/library?mode=…` | `(app)/(library)/library/index.tsx` → `src/components/library/library-screen.tsx` | The Library tab root: the browse modes `books` (absent = books), `authors`, `series`, `narrators`, `collections`, `folders` - see [The Library modes](#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 (search, a feed address, an OPML import): see [Podcasts](podcasts.md#finding-shows). The Library tab's own page, so a cold link (a reload, a pasted URL) lands in the Library and Back returns there. | | `/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 (how a series the listener owns nothing of is reached). At least one; 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` / `Book.narrator` is **exactly** `name` (a joint credit is one name, as the server lists it), series shelves, other books, 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 (per server, not per library: its items can span libraries). Owner: Edit, Share, Delete, Move up / Move down / Remove in the list view; viewer (`owned: false`): Leave. A `404` reads as deleted or no longer shared. | @@ -163,6 +165,7 @@ URLs, so every URL below is the same as before the groups existed. | `/profiles` | `src/app/profiles.tsx` → `src/components/profiles/profiles-screen.tsx` | Who's listening?: a root full-screen modal with no swipe-back (`ProfilesScreen`); see [Profiles](profiles.md#whos-listening-profiles). | | `/kids` | `src/app/kids.tsx` → `src/components/kids/kids-screen.tsx` | Kids mode, a root route outside the tabs with no swipe-back (`KidsScreen`); `KidsRouteGuard` keeps a kids profile here; see [Kids mode](kids-mode.md). | | `/demo` | `src/app/demo.tsx` | Public demo landing: mints a throwaway session on a demo-mode server and shows the pairing QR so the same demo user opens on a phone. | +| (any other path) | `src/app/+not-found.tsx` | The app's own themed "Page not found" with a way Home, in place of Expo Router's unbranded Unmatched Route (2.4.0). On the web, the server answers an unknown `/web/...` path with the player's `index.html`, so while the page hydrates it renders the same empty view and shows right after (`useHydrated`), which keeps React's hydration error #418 away. The `expo-router` plugin's `sitemap: false` in `app.json` turns the `_sitemap` route off. | ## The shell: tabs and navigation @@ -322,7 +325,11 @@ with a hint that says what it does (Up next first, then the series). When a book ends, the queue's head plays first ([what plays next](end-of-book.md#what-plays-next-up-next-resolverts)); the book that starts from it and the finished book leave it through `dropFromQueue` -(`src/components/player/end-of-book.ts`). +(`src/components/player/queue-drop.ts`). Any other start leaves it too +(`dropStartedBook`: a queued book started from its page, a browse row, the car or +Play now, once it is the book loaded). With an episode loaded, the suggestion's +label is "Next episode" and the footer switch is worded for Up next only (after an +episode, only Up next's head plays). ### Profile menu diff --git a/docs-developers/frontend/playback.md b/docs-developers/frontend/playback.md index 849f6ce8..3b0dfea9 100644 --- a/docs-developers/frontend/playback.md +++ b/docs-developers/frontend/playback.md @@ -635,7 +635,8 @@ local files (`switchCurrentBookToLocal`, preferring the engine's gapless ## Web transcode negotiation (`transcode.ts`) -Browsers can't decode some codecs a book may use (AC-3, E-AC-3, ALAC...). The +Browsers can't decode some codecs a book may use (AC-3, E-AC-3, ALAC...), nor +the Matroska container (`.mka`, indexed from server 2.4.0). The server marks those books `direct_playable: false` and, when it has ffmpeg (capability `transcode`), streams them re-encoded to MP3 with `?transcode=1&t=` ([cross-repo contract §5](../architecture/cross-repo-contract.md#5-transcode-negotiation-direct_playable-and-transcode1)). diff --git a/docs-developers/frontend/podcasts.md b/docs-developers/frontend/podcasts.md index f368a101..62080d0d 100644 --- a/docs-developers/frontend/podcasts.md +++ b/docs-developers/frontend/podcasts.md @@ -9,9 +9,11 @@ Bridge Phase 2d-1 shows a server's podcast libraries in the player, and Phase 2d-2 adds [finding shows](#finding-shows). The server lists a podcast library (`Library.kind === 'podcasts'`) only to a client that passes its [podcast gate](../server/auth-and-security.md#podcast-visibility): -the player from **2.2.0** (`app.json` `expo.version`, sent as -`X-AudioSilo-Client: AudioSilo/2.2.0 ()` by `src/lib/client-id.ts`), the -admin console and `AudioSilo/dev`. 2.1.x and older builds see no podcasts. +the player from **2.2.0** (`auth.PodcastPlayerMinVersion`, against +`app.json` `expo.version`, sent as `X-AudioSilo-Client: AudioSilo/ ()` +by `src/lib/client-id.ts`), the admin console and `AudioSilo/dev`. 2.1.x and +older builds see no podcasts. The first released player with these views is +2.4.0. The served `/web` export sends the same header same-origin. The cross-origin dev loop (`npm run web` on `:8081`) sends no header, so it shows no podcasts @@ -96,8 +98,13 @@ browser tab and renders: when `status.error` is set (no codes for listeners); - the order control: `defaultEpisodeOrder(show)` is `oldest` for a `serial` show and `newest` otherwise; the segmented control writes `?order=`. The - client never re-sorts: the server orders by published, then path; -- `EpisodeList`, a FlashList over `usePodcastEpisodes` pages. + client never re-sorts: the server orders by published, then season and + episode number, then path (server 2.4.0), so an undated serial lists as its + feed numbers it; +- `EpisodeList`, a FlashList over `usePodcastEpisodes` pages. A show link to a + library that isn't a podcast library, or isn't there or shared (`409`, + `404`, `403` on the show list), says the link points nowhere + (`library.detail.badLink`) with no Retry, which could not mend it. `visibleEpisodes` drops `removed && !downloaded` (a removed episode whose file is kept stays, marked "No longer in the feed"). @@ -110,10 +117,14 @@ size is known), Couldn't get it, Not available now. The two stopped states carry the server's `code`, and `rowStateReasonKey(state)` turns it into the reason line under them (`fetchReasonKey`; a server without `podcast_fetch` reads as `bridge_unavailable`, "Your server can't download episodes right -now."). The player's own fetch of that episode (`pending`) wins over the -list's batched `fetch`. The one button -is Play (Resume once started) through `usePlayBook`, or Try again after a -failure (`requestEpisodeFetch(..., {retry: true})`). Its menu is +now."), on a finished episode too. The player's own fetch of that episode +(`pending`) wins over the list's batched `fetch`. The one button +is Play (Resume once started) through `usePlayBook`, or Try again +(`requestEpisodeFetch(..., {retry: true})`) where `canRetryFetch` says so: after +a failure, or when the episode is not available for a reason this build has no +sentence for (no code, or an unknown one). A known reason (the module off, no +Bridge online, the library not writable) needs a person first, so Play stays +disabled with the reason beside it. Its menu is `BookActionsButton` with Add to Up next and Mark as finished, never Download. Tapping the row opens `bookHref`. @@ -122,23 +133,29 @@ Tapping the row opens `bookHref`. `book-page.tsx` renders `EpisodePage` (`episode-page.tsx`) for an item with `book.episode` (or, before the item arrives, any item of a podcast library). It has a hero with cover, title, a link to the show, date, duration and marks; -the primary Resume / Play / Try again; the description; the chapters through -`BookChaptersTab` (whose `id3_chap` caption reads "Chapters from the episode -file"); and `usePodcastFetchStatus` while the episode is not downloaded. A -stopped state's reason (`rowStateReasonKey`) sits beside the disabled primary -button. When +the primary Resume / Play / Try again (`canRetryFetch`, as on a row); the +description; the chapters through `BookChaptersTab` with `episode` (its card +reads "The whole episode" and its parts notice speaks of the episode, and its +`id3_chap` caption reads "Chapters from the episode file"); and +`usePodcastFetchStatus` while the episode is not downloaded. A stopped state's +reason (`rowStateReasonKey`) sits beside the disabled primary button. When `timeline_changed === true` and the listener has a position, a Notice says the audio changed and the place is kept: nothing is rescaled. There is no Generate, community metadata, rating, series or Download, and the player's companion chips and tabs are off for an episode (`useCompanionData` reads `book.episode` -as `off`). +as `off`). Community metadata is never asked for an episode (`metaEnabledFor` +in `meta-gating.ts` is false for `book.episode`: a feed's tags can carry an +ISBN-like value). ### Wording in the player and on Home The full player's progress reads `player.full.percentOfEpisode` (`{{percent}}% of the episode`) for an episode, and the Now card `home.now.throughEpisode` ("through the episode", with `scaleLabelEpisode` for its scale). The Now card -skips the "Chapter 1 of 1" line for an episode with no chapters. +skips the "Chapter 1 of 1" line, and resumes without a chapter in its label, +for an episode with no chapters, telling an episode by its library +(`useIsPodcastLibrary`) before its item loads. Up next's Now playing card leaves +out a chapter label that only repeats the title. ### Home's New episodes @@ -177,9 +194,10 @@ newer start options. **Phases.** `probing` (the stream is being asked), `waiting` (queued or on its way), `failed`, `unavailable`, `refused` (`409 fetch_limit`) and `starting`. -The wire rules (probe once, poll every `max(2, Retry-After)` seconds, pause in -the background, never POST by itself, abort without a server cancel, refresh -at ready) are in [Bridge podcasts](../bridge/podcasts.md#player-phase-2d-1). +The wire rules (probe once, poll every `max(2, Retry-After)` seconds and back +off on a network error, `429` or `5xx` with `blipDelay`, 2 seconds doubling to +60, pause in the background, never POST by itself, abort without a server +cancel, refresh at ready) are in [Bridge podcasts](../bridge/podcasts.md#player-phase-2d-1). Try again is `retryEpisodeFetch()` (the panel and strip) or `requestEpisodeFetch(target, book, {}, {retry: true})` (a row, the episode page): both POST once, then follow the same handling. @@ -204,7 +222,11 @@ Cancel or another start since then wins. A start that fails stops as `failed`. - `EpisodeFetchStrip` is the compact form, in `DockedPlayer` (wide) and `MiniPlayer` (phone, player closed). While another book plays it sits above that book's controls, which keep playing until the episode replaces it at - ready (decision 6.4). The iOS 26 `AccessoryPlayer` has no strip yet. + ready (decision 6.4). +- The iOS 26 `AccessoryPlayer` has no room for the strip: with nothing + playing the bottom accessory is the fetch itself (`AccessoryEpisodeFetch`, + with its Try again and Cancel); with a book playing meanwhile, the regular + accessory's second line says how the fetch is going (`useEpisodeFetchLine`). - A start long after the tap may be refused by a browser's autoplay rule: that is the existing "refusal is a pause" path, and the episode loads paused. @@ -221,7 +243,8 @@ this section covers the screens. ### The page -Route `src/app/(app)/(home,library,search,offline,me)/find-shows.tsx`, URL +Route `src/app/(app)/(library)/find-shows.tsx` (the Library tab's own page, so a +cold link lands in the Library and Back returns there), URL `/find-shows?connection=&library=[§ion=search|address|import]` (`findShowsHref`, `parseFindShowsParams` in `src/lib/paths.ts`, `openFindShows` in `src/lib/open.ts`; `/find-shows` is in `BROWSE_PATHS`). `FindShowsScreen` @@ -252,7 +275,10 @@ in `src/lib/open.ts`; `/find-shows` is in `BROWSE_PATHS`). `FindShowsScreen` follow requests. Too short a search shows "Type at least 2 characters." and sends nothing. The caption `Results from {{provider}}` credits the directory. A refusal reads through `searchErrorCopy` (with the - `Retry-After` seconds when known) above **Search again**. + `Retry-After` seconds when known) above **Search again**; on search a + `no_library_access` means the server shows this client no podcasts (an + older or unidentified player), so it reads "This app can't use podcasts on + this server. Update the app, then try again." `DiscoveryResultRow` (`discovery-result-row.tsx`) shows the artwork (`DiscoveryArtwork`, `discovery-artwork.tsx`, the retry schedule in `artwork-retry.ts`), title, author, episode count, `View on {{provider}}` @@ -260,14 +286,20 @@ in `src/lib/open.ts`; `/find-shows` is in `BROWSE_PATHS`). `FindShowsScreen` **Adding** (a `202` request) or **Following** with **Open show** (a `200`). An Adding result reads its request live (`useFollowRequests`, `requestEnd` in `find-shows-model.ts`): ended `done` with a show it turns Following with - Open show, ended otherwise it shows **Couldn't add** beside Follow again. + Open show, ended `cancelled` it shows **Cancelled** and ended otherwise + **Couldn't add**, each beside Follow again. Once a read list no longer + holds the request (`requestEnd` answers `unknown`: the server lists a + week's requests), the result offers Follow again alone. The country is `deviceCountry()` (`discovery-country.ts`). - **Feed address** (`follow-by-url.tsx`): the address field with a hint, a **Private feed** switch ("For a feed made just for you, like a paid or member feed..."), **Follow**. `feedAddressProblem` refuses an empty, non http(s) or over-2,048-byte address locally; a refusal from the server reads through `followByUrlErrorKey`. A `200` says `You follow {{title}}.` with - Open show; a `202` says the show appears in Shows once it's ready. + Open show, or, when it was asked as private but the library already has the + show as a public feed, `followingPublic` ("It was already in this library + as a public feed, so it isn't private."); a `202` says the show appears in + Shows once it's ready. - **Follow requests** (`follow-requests.tsx`, under Search and Feed address): "Shows being added", every open request then the ten newest ended ones, each with its redacted feed label, its state (`requestStateKey`: Waiting for @@ -283,18 +315,29 @@ in `src/lib/open.ts`; `/find-shows` is in `BROWSE_PATHS`). `FindShowsScreen` `file.bytes()`), then the file name, the summary (`{{settled}} of {{total}} shows followed or being added`, `importProgress`: duplicate and invalid rows don't count), the status line (a countdown to the next batch, ticking with - `useNow` while the import runs), **Stop** while it runs, **Try again** once + `useNow` while the import runs; a `429` wait reads "Imports can run about + once every 30 seconds.", a `503` one "Your server is busy."), **Stop** + while it runs, **Try again** once it stopped, stalled or failed, or ended `done` with `unavailable` rows (`canRetryImport`), and one row per subscription by `outline_index` (`Entry {{index}}`, `importRowKey`: Following, Already following, Being added, In the list twice, No usable feed address, Waiting - for the next batch, Can't be added right now, Couldn't add). The rows are - read live against the follow requests (`liveImportRows`): a queued row whose - request ended `done` reads Following, one that ended otherwise Couldn't - add. A `done` import's line is "The last shows are still being added." + for the next batch, Can't be added right now, Couldn't add, Cancelled). The + rows are read live against the follow requests (`liveImportRows`): a queued + row whose request ended `done` reads Following, `cancelled` Cancelled, and + otherwise Couldn't add with the request's error. A Couldn't add row carries + its reason (`importRowReasonKey`: the failed request's words when the + requests list it, else the row's code, `recently_failed` reading "It + couldn't be added a short while ago."). A row that is Couldn't add or + Cancelled and whose address the file holds (`outlineFeeds`, `rowFeedUrl` + in `opml-feeds.ts`) has its own **Try again** (`canRetryRow`, `retryRow`): + a single follow by that address, which the server always tries again, + whose answer stands over the waves' row (`withRetries`). A `done` import's line is "The last shows are still being added." while a row is still queued (`stillAdding`), then `{{count}} shows can't be added right now. Try again later, or ask the person who runs your server.` - when `unavailable` rows remain (`unavailableRows`), else "All done.". A + when `unavailable` rows remain (`unavailableRows`), then + `doneFailed` ("Done. 3 shows couldn't be added. You can try each one again + below.") when Couldn't add rows remain (`failedRows`), else "All done.". A row's Private feed badge shows only when it has a usable feed (not `invalid` or `duplicate`, `feed` set), and a row with no label shows only its entry and status. A file over 1 MiB or one that can't be read is a line @@ -342,8 +385,10 @@ locales; none of it says "Bridge" or shows the server's English. [Offline](offline.md). - **Cast.** `NowPlaying.episode` hides the Cast button (`castButtonModel`), except while a session is already connected, so it can - be stopped. Cast tokens never cover podcasts. See - [Chromecast](chromecast.md). + be stopped. Cast tokens never cover podcasts: an episode started while a + book is cast is refused before a token is asked (`CastBlockedError` + `episode`, toast `cast.blocked.titleEpisode`), and a server's `403 + not_a_book` reads the same. See [Chromecast](chromecast.md). - **Open the end credits.** `BookEndedListener` drops the episode from Up next and, with auto-play on and not under the sleep timer, starts Up next's head only (`resolveUpNext(..., {queueOnly: true})`), never a series or diff --git a/docs-developers/frontend/testing.md b/docs-developers/frontend/testing.md index b82e18d9..8b1d05ec 100644 --- a/docs-developers/frontend/testing.md +++ b/docs-developers/frontend/testing.md @@ -1,6 +1,6 @@ --- title: Testing the player -description: "The jest-expo harness, the checks npm test runs before jest, the global mocks in jest.setup.ts (including FlashList's measurements), the conventions that keep logic testable, rendering covers and grids, the overlay and route-tree harnesses, the patterns for mocking fetch, reachability, and Platform.OS, and the native checks (the Android JVM tests, the iOS self-check, Android Auto on the Desktop Head Unit, CarPlay in the Simulator, on-device checks)." +description: "The jest-expo harness, the checks npm test runs before jest, the global mocks in jest.setup.ts (including FlashList's measurements), the conventions that keep logic testable, rendering covers and grids, the overlay and route-tree harnesses, the patterns for mocking fetch, reachability, and Platform.OS, and the native checks (the Android JVM tests, an Android release build, the iOS self-check, Android Auto on the Desktop Head Unit, CarPlay in the Simulator, on-device checks)." --- Every piece of new logic in the frontend ships with a unit test. The harness is @@ -323,7 +323,7 @@ Co-located suites exist for: | Playback | `src/playback/book-queue.test.ts`, `progress-sync.test.ts`, `store.test.ts`, `service.web.test.ts`, `sleep-timer.test.ts`, `auto-sleep.test.ts`, `auto-sleep-controller.test.ts`, `rate.test.ts`, `next-book.test.ts`, `prettify-title.test.ts`, `types.test.ts`, `transcode.test.ts` (the web negotiation rules), `up-next-resolver.test.ts` (what plays next), `up-next-sources.test.ts`, `jump-undo.test.ts`, `time-left.test.ts`, `drift.test.ts`, `drift-controller.test.ts`, `last-interaction.test.ts`, `use-shake-to-extend.test.ts` (the burst detector and sensitivity tunings), `place-reconcile.test.ts` (a loaded book picks up another device's place), `engine-ticks.test.ts` (`engineTicker`), `service.native.test.ts` (the native bridge: the newer events, `load`'s `book` only on a binary that takes it), `store-adopt.test.ts` (`adoptLoaded`: no engine reload, saves under the adopted book, the floor is the adopted place), `effects.test.ts` (where Smart speed and Voice boost apply), `time-saved.test.ts`, `book-source.test.ts` (where a book plays and resumes from), `store-race.test.ts` (the bridge created once however many callers race; a remote move while a book loads is ignored) | | Downloads | `src/downloads/store.test.ts`, `offline-meta.test.ts`, `offline-companion.test.tsx` (a downloaded book's community data end to end, offline), `keep-ahead.test.ts`, `keep-ahead-controller.test.ts`, `failure.test.ts`, `downloads-view.test.ts`, `use-download-controls.test.ts`; `src/components/downloads/downloads-screen.test.tsx` | | Car and widgets | `src/car/car-model.test.ts` (the snapshot, item ids, play specs), `car-artwork.test.ts`, `car-controller.test.ts` (writes, play requests, bookmarks, adopting), `car-task.test.ts`; `src/widgets/widget-model.test.ts`, `widget-sync.ios.test.ts`; `src/lib/bootstrap.test.ts` (the shared launch steps) | -| Config plugins | `plugins/withCarPlay.test.ts` (the scene manifest, the AppDelegate rewrite over an SDK 56 fixture, the default-on entitlement and its `AUDIOSILO_CARPLAY=0` opt-out), `plugins/withCast.test.ts` (the Cast options, the Android expanded controller, both plugins in `app.json` order over the SDK 56 AppDelegate), `plugins/withWidgetsNoPush.test.ts` | +| Config plugins | `plugins/withCarPlay.test.ts` (the scene manifest, the AppDelegate rewrite over an SDK 56 fixture, the default-on entitlement and its `AUDIOSILO_CARPLAY=0` opt-out), `plugins/withCast.test.ts` (the Cast options, the Android expanded controller, both plugins in `app.json` order over the SDK 56 AppDelegate), `plugins/withWidgetsNoPush.test.ts`, `plugins/build-properties.test.ts` (the `expo-build-properties` options: cleartext http and R8 on Android, React Native from source on iOS) | | Cast | `src/cast/cast-media.test.ts` (the pure casting rules), `cast-token.test.ts`, `cast-prepare.test.ts`, `cast-service.test.ts`, `routed-service.test.ts` (the route switch), `cast-session.test.ts`, `cast-store.test.ts`, `cast-button-model.test.ts`, `cast-button.test.tsx`, `driver.native.test.ts`, `driver.web.test.ts` (one tab owns a session), `sdk-shapes.test.ts`, `web-sender.test.ts` | | Listening in the house | `src/components/house/house-model.test.ts`, `house-row.test.tsx`, `in-the-house-card.test.tsx`, `house-sharing-row.test.tsx`; `src/components/series/portrait.test.tsx` (the avatar and its ring); `src/api/hooks-household.test.tsx` (gating and polling) | | Profiles | `src/stores/profiles.test.ts`, `src/lib/profile-pin.test.ts`, `sha256.test.ts`, `random.test.ts`; `src/components/profiles/profiles-model.test.ts`, `profiles-screen.test.tsx`, `pin-dialog.test.tsx`; `src/components/shell/auth-gate.test.tsx`, `profile-menu.test.tsx` | @@ -379,6 +379,7 @@ Simulator. Jest and the web prove nothing about Swift or Kotlin. | Android Auto (the service, the browse tree, the `MediaItems` queue builder and extras, the car snapshot, the car JS) | [the DHU](#android-auto-on-the-desktop-head-unit) | | CarPlay, the UIScene life cycle (`withCarPlay.js`, the scene delegates) | [the CarPlay Simulator](#carplay-in-the-ios-simulator) | | Lock screens, the audio effects by ear, widgets, the Live Activity, the token store | [a device](#on-devices) | +| A native dependency added or upgraded, `android.extraProguardRules` | [an Android release build](#an-android-release-build) | ### Android JVM tests @@ -393,6 +394,22 @@ cd android Gradle needs JDK 17; point `JAVA_HOME` at it if the default is newer. +### An Android release build + +Release builds run R8 (minify and resource shrinking, see +[releasing](../contributing/releasing.md#the-app-store-track-ios--android)); debug builds +don't, so only a release build shows a class or resource R8 stripped. `run:android` reuses +an existing `android/` folder without applying `app.json` again, so prebuild first: + +```sh +CI=1 npx expo prebuild -p android --no-install +npx expo run:android --variant release # signed with the debug keystore +``` + +Then exercise what the dependency does (playback, Cast, Android Auto) and watch +`adb logcat` for a `ClassNotFoundException`, `NoSuchMethodError`, `NoSuchFieldError` or +`Resources$NotFoundException`. + ### The iOS self-check No Simulator, just the Xcode toolchain: it compiles `VoiceBoostDSP.swift` and diff --git a/docs-developers/server/api/index.md b/docs-developers/server/api/index.md index bd8cf127..029011e4 100644 --- a/docs-developers/server/api/index.md +++ b/docs-developers/server/api/index.md @@ -567,8 +567,11 @@ CORS is a strict allow-list driven by `cors_origins` in the server config: - **Unset (default):** no CORS headers are ever emitted. Cross-origin browser requests fail preflight; native apps and same-origin web clients (including the player served at `/web`) are unaffected. -- **Listed origins:** an exact-match `Origin` gets - `Access-Control-Allow-Origin: ` (plus `Vary: Origin`), methods +- **Listed origins:** every answer carries `Vary: Origin` (from 2.4.0: with no + `Origin`, a denied one and preflights too, so a browser cache never reuses an + answer made without the grant for a granted origin, or the other way round). + An exact-match `Origin` gets + `Access-Control-Allow-Origin: `, methods `GET, POST, PUT, PATCH, DELETE, OPTIONS`, request headers `Authorization, Content-Type, X-AudioSilo-Client, Range` (`Access-Control-Allow-Headers`), the exposed response headers @@ -590,8 +593,8 @@ headers are `Retry-After` where a route sends one (the discovery and OPML budgets, a busy transcode, an episode on its way; the general rate limit and the request timeout send none) and the OPML export's counts, so a cross-origin web player reads them as the `/web` player and native apps -always could. A denied or absent origin, and a server with `cors_origins` -unset, still gets no CORS header at all. An older server exposes nothing, so +always could. A denied or absent origin gets no CORS header but `Vary`, and a +server with `cors_origins` unset gets none at all. An older server exposes nothing, so the player falls back to the body's `retry_after_seconds` for an episode fetch and reads the export counts as unknown. diff --git a/docs-developers/server/api/reference.md b/docs-developers/server/api/reference.md index 16bc49f4..290a4e00 100644 --- a/docs-developers/server/api/reference.md +++ b/docs-developers/server/api/reference.md @@ -400,11 +400,21 @@ on a server with that capability - the same shape as `/auth/exchange`. ### `POST /api/v1/auth/pair` *Session.* Issues a fresh pairing payload for the calling user - "add another -device" from an existing session. No request body. Response `200`: a +device" from an existing session. The body is optional: + +```json +{ "ttl_seconds": 120 } +``` + +Response `200`: a `PairingPayload` (same shape as `/auth/redeem`, without the invite budget fields). Its token is single-use and lasts 10 minutes (`pairingTTL`), so the player counts down from -when the payload arrived. The admin console's **Listen in player** uses it too, to sign the -web player in as the admin in a new tab. An API key gets `403`. A kid's account (a [household member](#household-members) +when the payload arrived. `ttl_seconds` (from 2.4.0) asks for a shorter life, clamped to +30 seconds through 10 minutes (`pairTTL`): a caller can shorten a token's life, never +lengthen it; no body or no field is the 10 minutes, and a malformed body is `400 +invalid request`. The admin console's **Listen in player** uses it too, to sign the +web player in as the admin in a new tab, asking 120 seconds (`PLAYER_TAB_TTL_SECONDS`), +since that token rides the tab's URL and history. An API key gets `403`. A kid's account (a [household member](#household-members) with `member_kind: "kid"`) gets `403` `code: "kid_account"`: its owner sets up its devices with [`POST /me/household/members/{id}/pair`](#post-apiv1mehouseholdmembersidpair). @@ -470,7 +480,9 @@ Either field is omitted when unknown, and the answer is `{}` when neither is. The routes in this section but `DELETE /auth/recovery` (which only reduces access, so it is neither limited nor refused), together with the [API keys](#personal-api-keys) -below, share one rate limit (10 attempts per IP per 15 minutes) and are +below (but their listing, `GET /auth/tokens`, which is not counted), share one rate +limit (10 attempts per IP per 15 minutes; a refusal is `429` with `Retry-After`, the +seconds until the lockout ends) and are **refused for demo accounts** (403 `code: "demo_account"`, `not available for demo accounts`), so a throwaway session can't mint a durable credential. `POST /auth/password`, `POST /auth/recovery` and `POST /auth/tokens` (and `POST /auth/pair`) @@ -536,8 +548,9 @@ integrations - dashboards, cron jobs, monitoring. A key authenticates on any its owner**: an admin's key also passes *Admin* routes, a regular user's does not. A pairing token is never accepted as a bearer credential, and an API key is never valid for `/auth/exchange`. Revocation is the only lifecycle - a key never -expires. All three routes share the [self-service](#self-service-account) rate -limit (10 attempts per IP per 15 minutes) and are **refused for demo accounts** +expires. Creating and revoking share the [self-service](#self-service-account) rate +limit (10 attempts per IP per 15 minutes; a refusal is `429` with `Retry-After`); +listing does not spend it (from 2.4.0). All three are **refused for demo accounts** (403). **Containment.** A key cannot mint a *fresh durable credential*. A request that @@ -1136,8 +1149,13 @@ filtered out returns **empty lists**, not `403`. A hidden individual show is Episodes require `show=`. `order` is `newest` (default) or `oldest`; `limit` defaults to 50 and must be 1 through 200 (`400 invalid_request` otherwise). -Pagination uses an opaque `cursor` with a published/path keyset; it is bound -to show and order. Invalid order/cursor gives `400 invalid cursor`. +Episodes are ordered by `published` (undated as the earliest), then +season (none counts as 0), then episode number (unnumbered before numbered, +oldest first), then path, the order the Bridge's `feed.json` uses, so an +undated serial such as a LibriVox reading lists as its feed numbers it. +Pagination uses an opaque `cursor` over that keyset; it is bound to show and +order, and a cursor from a server before 2.4.0 (a published/path keyset) is +refused. Invalid order/cursor gives `400 invalid cursor`. Per-episode grants are applied before pagination. A **Show**: @@ -1406,7 +1424,7 @@ sends a finished/failed notification. See [the Bridge implementation](../../brid shows by name; following one is the ordinary [`POST /podcasts/follow`](#podcast-actions) with `{library_id, feed_url}` (there is no provider-id follow and no new follow field). The player's Add -shows page calls them (players from 2.2.0, +shows page calls them (the player from 2.4.0, [Phase 2d-2](../../bridge/podcasts.md#player-discovery-and-opml-phase-2d-2)); the console does not. @@ -1664,7 +1682,7 @@ subscriptions. For example, a list whose first outline is a category: | `invalid` | `error.code` `missing_feed_url` or `invalid_feed_url` | `error` | | `limited` | `error.code` `too_many_requests`: the wave is full (below) | `error` | | `unavailable` | `error.code` `bridge_unavailable`, `library_not_writable` or `module_off` | `error` | -| `error` | `error.code` `privacy_conflict`, `no_library_access` or `subscribe_failed` | `error` | +| `error` | `error.code` `privacy_conflict`, `no_library_access`, `subscribe_failed` or `recently_failed` | `error`; `request_id` for `recently_failed` | `feed` is the redacted label even for public URLs, and `null` for an invalid or missing URL; `private` follows the usual automatic detection, a @@ -1682,6 +1700,19 @@ Bridge offline, and only a new feed or a secret repair needs an online Bridge running `podcast.subscribe` and a writable mapping. Each row commits on its own: a later failure never undoes an earlier row. +**Recently failed feeds.** When the caller's last `podcast.subscribe` request +for a feed in this library failed within the last hour +(`podcast.RecentFailureWindow`), the row answers `error` with +`error.code: "recently_failed"` and `request_id` set to that failed request, +and nothing is queued again, so a few dead feeds cannot take every wave's +slots. Only a failure of the feed itself counts (its address, its answer, the +network): a request that ended `request_expired` (a server restart), +`bridge_lost`, `library_not_writable`, `not_podcast_library`, `write_failed`, +`feed_secret_missing` or `identity_changed` does not (`notFeedFailures` in +`internal/bridge`), nor one the caller has asked again since. A single [`POST /podcasts/follow`](#podcast-actions) by +address always retries; that is the player's Try again. Older players show the +row as a plain error. + **Waves.** One upload creates at most **five new** subscribe requests, and the caller can never have more than five open subscribe requests at once (single follows and imports together, admins included). Following known shows and @@ -1752,7 +1783,10 @@ left out as unavailable, and one whose URL now looks private as private. The omission counts count follows before duplicate URLs are merged. More than 10,000 matching follows, or an export over 4 MiB, is `413 opml_export_limit` -(never a truncated list). Fetch it with the `Authorization` header; there is no +(never a truncated list); the two refusals share the code and their +`error.message` says which limit was hit ("Too many subscriptions to export +(more than 10,000)." or "The subscription list is too large to export (over +4 MiB)."). Fetch it with the `Authorization` header; there is no `?token=` download link. ### Podcast storage (admin) @@ -1777,9 +1811,9 @@ are shared per library/show path/show id and survive index rebuilds; replacing a show at the same path starts with the default. PUT/DELETE wake scheduling and do not wait for disk changes. There is no retention UI yet. -Keep downloads the newest N currently listed episodes (published/path -descending, undated last); Archive downloads all currently listed episodes -oldest first and never deletes. On demand schedules no feed-wide downloads; +Keep downloads the newest N currently listed episodes (the +[episode order](#podcast-read-routes) descending, undated last); Archive +downloads all currently listed episodes oldest first and never deletes. On demand schedules no feed-wide downloads; play, one-ahead queue preparation and admin fetch work. No followers means no policy-scheduled downloads/deletions; authorized play/Retry and queue preparation still work. Removed feed entries are not @@ -1901,7 +1935,8 @@ single-book response (and [`/chapters`](#get-apiv1librariesidchapters)) carries it. `direct_playable` reports whether the codec plays natively in browsers (unknown -codec ⇒ `true`). The web player streams a `false` book with `?transcode=1` when +codec ⇒ `true`); a book with a Matroska (`.mka`) file is always `false`, whatever +its codec (from 2.4.0). The web player streams a `false` book with `?transcode=1` when the `transcode` capability is true; it does not retry through the transcoder after a failed direct play. Durations/positions are seconds (float). @@ -2075,6 +2110,14 @@ Otherwise the local steps decide, in order, and the first one with an answer win An unreadable folder falls through. 4. **`none`** - `{"source": "none"}`. +After a **podcast episode** (a path in a podcast library) none of those steps +run: the answer is the next episode of its show in the +[episode order](#podcast-read-routes) oldest first (whichever way the show +lists), among the episodes in scope that can still play (in the feed, or kept +on disk), as a `folder` answer with `next` and `book`; `{"source": "none"}` at +the show's end. Episode file names say nothing about their order, so the +folder's name order is never used (`catalog.NextPodcastEpisode`). + | Status | Meaning | |---|---| | `400` | missing `path` / invalid library id | @@ -4060,7 +4103,7 @@ person keeps, what ends it and how dead ones are pruned: | `201` | `{ token, expires_at }` | | `400` | `invalid request` (a malformed body or an unknown field), `path is required` | | `401` | no session (a cast token can't mint another: it is never a bearer credential) | -| `403` | the library or the path is outside the caller's access; or `not available when authenticating with an API key` (a key never mints another credential) | +| `403` | the library or the path is outside the caller's access; `error.code: "not_a_book"` (`Only books can be cast.`) for a path in a podcast library, since episodes are not cast (from 2.4.0; before, the same 403 with no code); or `not available when authenticating with an API key` (a key never mints another credential) | | `404` | `book not found` (no book at the path), or `library not found` (an admin naming an unknown library; anyone else gets the `403`) | | `500` | `could not start casting`; `access check failed`, `could not load library` or `could not load book` on a database failure | @@ -4439,7 +4482,7 @@ settings are not echoed - read them from | Status | Meaning | |---|---| | `400` | `name and root are required`; `invalid request`; `code: "invalid_schedule"` (a schedule that isn't one of the forms above); `code: "invalid_pattern"` (too many patterns, one too long, one that matches nothing, or a malformed wildcard - the message names the line); `code: "invalid_metadata_source"` (a `metadata_source` other than `"tags"` or `"path"`) | -| `409` | `name already taken` (`code: "name_taken"`) | +| `409` | `name already taken` (`code: "name_taken"`); also `name_taken` when the server's config declares a library by this name at another folder (the next start would give it the declared folder; the library is deleted, say, and not back yet) | Invalid `kind` is `400 invalid_kind`. If either library is a podcast library, equal, ancestor or descendant roots conflict (`409 root_overlaps`), using @@ -4471,6 +4514,18 @@ Sending the existing `kind` is accepted; a different kind is `400 kind_immutable`. Root changes enforce the same `409 root_overlaps` rule as create. +A library the server's config declares (`config.yaml`, or the app embedding +the server: `launcher.Options.Libraries`; no environment variable declares one) keeps the name and +folder declared there, since every start upserts it by name +(`launcher.syncLibraries`) and the server never writes a library to +`config.yaml`. A different `name` or `root` on such a library is +`409 config_library` (from 2.4.0; before, the rename stuck until the next +start brought the declared library back as a second one); the same values +resent write nothing, and its other settings stay editable. The console reads +the declared names from `GET /admin/setup`'s `config_libraries` and locks the +fields. Renaming another library to a declared name at another folder is +`409 name_taken`. + A rescan is queued (trigger `change`) **only** when the root or the ignore rules change, since those make the index stale. A rename, a new default view or a new schedule doesn't rescan. Nor does a new `metadata_source`: every book of the @@ -4945,7 +5000,7 @@ what the first does). | `genre` · `tag` | string | - | a genre (tag) the book has, one value each, case-insensitive with spacing collapsed, as on the [player's list](#get-apiv1librariesidbooks) | | `format` | string, repeatable | - | `?format=m4b&format=mp3`; at most 50 values | | `codec` | string, repeatable | - | ffprobe codec name (`aac`, `mp3`, …); at most 50 values | -| `direct_playable` | `true`\|`false` | - | the book's codec plays in browsers (an unknown codec counts as playable) | +| `direct_playable` | `true`\|`false` | - | the book's codec plays in browsers (an unknown codec counts as playable) and no file of it is in a container no browser plays (`.mka`) | | `has_cover` | `true`\|`false` | - | a sibling image, embedded art or a custom cover | | `has_chapters` | `true`\|`false` | - | **more than one** chapter (every single-part book has one) | | `matched` | `true`\|`false` | - | the book has an ASIN or an ISBN | @@ -5747,15 +5802,16 @@ scope below); it never changes the identifier itself. Only books a scope would change are recorded (a book already up to date leaves no item, as a repick records only books with a new ASIN), and each refresh proposal names the identifier it was found by (`proposal.found_by`, `"asin:"` or -`"isbn:"`): applying it changes nothing on a book that no longer has that -identifier (matched again since, by hand). +`"isbn:"`), as each repick proposal names the ASIN it looked up (from +2.4.0; a repick recorded earlier has none): applying it changes nothing on a +book that no longer has that identifier (matched again since, by hand). Each book's **outcome**: | Outcome | Meaning | |---|---| | `auto` | Confident: the best candidate scores **90** or more, at least **10** ahead of the next, its recording has an ASIN or ISBN, and, when both lengths are known, the recording's runtime is within **10%** of the book's (the closest recording of a work can still be another edition). Applied unless the admin leaves it out. A repick's offers are all `auto`. | -| `review` | A candidate that isn't confident. Applied only if the admin puts it in. | +| `review` | A candidate that isn't confident. Applied only if the admin puts it in. One held because its recording's length is more than 10% from the book's (a match run's or a refresh's) carries `detail: "length_differs"` (from 2.4.0), worded with the item's `book.duration` and the proposal's `runtime_min`. | | `none` | No candidate. | | `error` | The service failed for this book (`detail: "metadata_unavailable"`). Five in a row stop the run. | @@ -5854,7 +5910,7 @@ item's score, so every page follows one order), `?limit=` (1-200, default 50). "published": "2011", "asin": "B0UK000001" } }, "applied": "", - "book": { "title": "The Martian (Unabridged)", "author": "Andy Weir" }, + "book": { "title": "The Martian (Unabridged)", "author": "Andy Weir", "duration": 38052.4 }, "changes": { "ids": { "fields": ["asin"], "cover": false }, "fill": { "fields": ["narrator", "published", "asin"], "cover": true }, @@ -5872,8 +5928,8 @@ item's score, so every page follows one order), `?limit=` (1-200, default 50). identifier's, else the closest runtime (a recording selling in the preferred marketplace wins a tie), and its ASIN is that recording's best for this server, named by `asin_region`. -- `book` is the book as it is now; `gone: true` when nothing is indexed at the - path any more. +- `book` is the book as it is now, with its `duration` in seconds (omitted when + unknown); `gone: true` when nothing is indexed at the path any more. - `matched_since: true` (a match run only) when the book has an ASIN or ISBN now that it didn't have when the run looked: matched since, by hand or otherwise. Its `changes` are empty, and an apply leaves it alone under any scope. @@ -5932,7 +5988,9 @@ range (an omnibus, `"1-3"`) is written as one: `series_index` `"1-3"`, or a no position. A retired work id is a redirect the server follows, so a merged record's series are the ones written. A repick writes its ASIN whatever the scope, and only while -the book's ASIN is still the community override it found; a refresh takes only +the book's ASIN is still the community override it found and still the ASIN it +looked up (`found_by`), so a book matched again by hand since, to another +record, keeps that match; a refresh takes only `fill` or `overwrite` (`400` for `ids`, which would write nothing). Audited as `book.match_apply` (`run`, `books`, `scope`). Applying `ready` again after a stopped apply takes the rest. @@ -6218,12 +6276,13 @@ a scan last finished. ### `GET /api/v1/admin/issues/duplicates` Groups of books that look like the same book **within one library** (copies in -different libraries are deliberate and never grouped). +different libraries are usually deliberate and are grouped only with `across`). | Query param | Type | Notes | |---|---|---| | `library_id` | int | one library; a non-positive or non-integer id is `400 invalid library_id` | | `ignored` | `true` | return **only** the groups an admin said are different books (instead of the open ones) | +| `across` | `1` or `true` | also group copies in **different** libraries (2.4.0, the console's "Include other libraries"): each copy's row names its `library_id` and `library_name`, and with `library_id` the groups holding one of that library's books are kept, their copies from any library. Without it nothing changes; the Health `duplicate` count never uses it | ```json { @@ -7024,8 +7083,8 @@ a cross-user "currently listening" feed (up to 200 rows, newest first; `title`/ "total_libraries": 2, "total_users": 5, "libraries": [ - { "id": 1, "name": "Audiobooks", "book_count": 1201 }, - { "id": 2, "name": "Kids", "book_count": 83 } + { "id": 1, "name": "Audiobooks", "kind": "books", "book_count": 1201 }, + { "id": 2, "name": "Kids", "kind": "books", "book_count": 83 } ], "listening": [ { @@ -7044,6 +7103,9 @@ a cross-user "currently listening" feed (up to 200 rows, newest first; `title`/ } ``` +Each `libraries[]` entry carries its `kind` (`books` or `podcasts`, from +2.4.0), so the dashboard's "Books per library" lists books libraries only. + #### `?range=`: listening activity | Query param | Type | Default | Notes | @@ -8031,8 +8093,9 @@ server and this request: under them or under `/etc`, by whole path segments, so `/devices` is offered), not pseudo filesystems, not the server's data folder, sorted, at most 20. `[]` on other - systems, when the table can't be read, and once the flow is finished (only - its folder step offers them). + systems, when the table can't be read, and once the flow is finished, unless + its folder step was skipped and can still be reopened from the Overview card + (only that step offers them). | Status | Meaning | |---|---| diff --git a/docs-developers/server/auth-and-security.md b/docs-developers/server/auth-and-security.md index c4b11b97..3c495227 100644 --- a/docs-developers/server/auth-and-security.md +++ b/docs-developers/server/auth-and-security.md @@ -63,6 +63,13 @@ and the [Bridge's token](#bridge-tokens) (`auth.KindBridge`): (multi-scan within it, since recovery codes are unlimited); `/auth/pair`, a household member's code (`POST /me/household/members/{id}/pair`) and demo tokens are unlinked - single-use (revoked on exchange) with the same 10-minute TTL. + `/auth/pair` can ask for a shorter one (`ttl_seconds`, clamped to 30 seconds + through 10 minutes by `pairTTL`); the console's Listen in player asks 120 + seconds, since its token rides a tab's URL and history. + The daily retention job deletes the pairing tokens that can never pair again + (`auth.PrunePairingTokens`: revoked, past their own expiry, or linked to an + expired or used-up code), so the rows don't pile up one per QR shown; the + code's own row is untouched. - **API keys** (`auth.KindAPI`) are user-minted, **non-expiring** bearer credentials for headless integrations (dashboards, cron), created / listed / revoked via `POST` / `GET` / `DELETE /auth/tokens` @@ -71,9 +78,12 @@ and the [Bridge's token](#bridge-tokens) (`auth.KindBridge`): passes `requireAdmin` - but it is never valid for `/auth/exchange` (pairing), and its lifecycle is mint / list / revoke rather than sign-in / sign-out. Only the SHA-256 hash is stored (the secret is returned once at creation) and the - user's label rides in `tokens.device_name`. All three routes go through + user's label rides in `tokens.device_name`. Creating and revoking go through `gateSelfService`, so they share the `accountLimiter` and are refused for demo - accounts, exactly like the password/recovery routes. **Containment:** an API-key + accounts, exactly like the password/recovery routes; listing (`GET + /auth/tokens`) takes `nonDemoAccount` alone (from 2.4.0), so opening the + Account page never spends the per-IP budget a household behind one NAT + shares. **Containment:** an API-key caller is additionally barred (403, via `denyAPIKey` in `internal/api/handlers_auth.go`, also called by `handlers_cast.go` and `handlers_members.go`) from the credential-minting routes @@ -853,14 +863,34 @@ Two mechanisms in `internal/api/ratelimit.go`, six buckets wired in `api.New`: | `loginLimiter` | Failure lockout per IP (`limiter`) | 10 failures / 15 min | `POST /auth/login` | | `redeemLimiter` | Failure lockout per IP | 10 failures / 15 min | `POST /auth/redeem`, and the Bridge's pairing exchange `POST /bridge/pair` (one shared counter) | | `demoLimiter` | **Attempt** cap per IP (`Acquire`) | 5 / 15 min | `POST /demo/session` | -| `accountLimiter` | **Attempt** cap per IP (`Acquire`) | 10 / 15 min | `POST /auth/password`, `POST /auth/recovery`, `/auth/tokens` (create/list/revoke); adding a household member, and a member's session and pairing code (`POST /me/household/members`, `.../{id}/session`, `.../{id}/pair`), charged only after the API-key, demo and member refusals | +| `accountLimiter` | **Attempt** cap per IP (`Acquire`) | 10 / 15 min | `POST /auth/password`, `POST /auth/recovery`, `POST /auth/tokens` and `DELETE /auth/tokens/{id}` (create/revoke; listing is not counted); adding a household member, and a member's session and pairing code (`POST /me/household/members`, `.../{id}/session`, `.../{id}/pair`), charged only after the API-key, demo and member refusals | The failure-lockout `limiter` has two usage patterns: `Allowed`/`Fail`/`Reset` counts only *failed* attempts (right for login/redeem, where success should not lock anyone out), while `Acquire` atomically admits-and-counts every attempt - used for account-creating/mutating endpoints where even successful requests must be metered and a check-then-count pair could race. Both structures sweep -stale entries so a flood of distinct IPs can't grow memory without bound. +stale entries so a flood of distinct IPs can't grow memory without bound. An +`accountLimiter` refusal (`acquireAccountBudget`) is a `429` with +`Retry-After`, the seconds until the lockout ends (`LockedFor`, rounded up). + +**Podcast discovery and OPML** have budgets of their own, per user and in +memory (reset by a restart), outside the six buckets above +(`internal/podcast/discovery`, `handlers_podcast_discovery.go`; full tables in +the [reference](api/reference.md#podcast-discovery)): + +| Budget | Limit | Refusal | +|---|---|---| +| Directory search, per user (both providers, cache hits included, admins too) | 10 / min, burst 3 | `429 discovery_rate_limited` | +| Directory fetches, server-wide | iTunes 20 / min, burst 1; Podcast Index 30 / min, burst 2; 2 at once | `429 provider_rate_limited` | +| Search artwork, per user | 120 / min, burst 20 | `429 discovery_rate_limited` | +| Artwork image fetches, server-wide | 60 / min, burst 4; 4 at once | `429 provider_rate_limited` | +| OPML import, per user | 2 / min, burst 1; 2 imports at once server-wide | `429 opml_rate_limited`, or `503 opml_busy` | +| OPML export, per user | 6 / min, burst 2 | `429 opml_rate_limited` | + +Each refusal carries `Retry-After`. The per-user tables hold at most 4,096 +users (`503 opml_busy` for OPML once full), and an upstream `429` cools that +provider down for its `Retry-After` (1 to 300 seconds, 60 by default). **Media is classed by route.** A cover grid loads every cover as its own `?token=` request, so sharing the per-IP budget turned a fast server's covers @@ -906,7 +936,10 @@ NAT or proxy gateway hands over from a private address on https. CORS (`cors` middleware) is a strict allow-list from `cors_origins`; with no configured origins, cross-origin browser requests simply get no CORS headers -(native apps and same-origin web are unaffected). +(native apps and same-origin web are unaffected). Once any origin is +configured, every answer carries `Vary: Origin` (`liveConfig.corsOn`), granted +or not, so a cached cover or answer made without the grant is never reused +for a granted origin's request. Non-streaming requests are bounded by a 30 s timeout (`requestTimeout`) that cancels the request context and returns 503 - resilience against a stuck @@ -939,7 +972,9 @@ Details and the reasoning live in [Web UI](web-ui.md). Secrets that must ride in a URL go in the **fragment**, which browsers never send to the server - so they cannot appear in server or proxy logs: -- Invite links: `/connect#code=…` (the connect page auto-redeems it). +- Invite links: `/connect#code=…` (the connect page auto-redeems it; from + 2.4.0 the app's scanner and the web player's paste field read it too and hand the + code to their sign-in step in memory, never in a route). - First-run setup: `/setup#token=…` (the POST then verifies the token in constant time). @@ -950,7 +985,7 @@ class as the invite link itself), which is acceptable because the server keeps no access log, sends `Referrer-Policy: no-referrer`, and the token dies with the invite (revoke, rotate, supersede, expiry, use cap) - the exposure class matches the media `?token=` fallback. Recovery-derived and `/auth/pair` -tokens stay 10-minute-bounded. Follow the fragment convention for any new +tokens stay 10-minute-bounded (Listen in player's 2-minute). Follow the fragment convention for any new durable secret. ## The allowed + denied test rule diff --git a/docs-developers/server/community-chapters.md b/docs-developers/server/community-chapters.md index bc10b46b..8783e3d4 100644 --- a/docs-developers/server/community-chapters.md +++ b/docs-developers/server/community-chapters.md @@ -241,9 +241,13 @@ is a chapter source **between the community's and the files'**: `files` or `community` keeps it out), - the **community's chapters don't apply** (no current fitted check that is chosen or a `fill`), -- the list's **files are the book's** (`sameChapterFiles`: as many, in the same - order, each the same path relative to the book's folder, a single-file book's - being its file name, and the same size), and +- the list's **files are the book's** (`chaptersOnFiles`: as many, each the + same path relative to the book's folder, a single-file book's being its file + name, and the same size). In the same order the chapters are as listed; in + another (a list the Bridge found while the book played its files in byte + order, before 2.4.0's natural order) they move with their files + (`movedChapters`: each chapter starts at the same place in the same file, by + the listed lengths, and runs to the next start in the new order), and - `chapteralign.Align` with **no `Prober`** (no snapping: the list is already timed on this audio) answers **`fill`**, the fit's word for "the book has no chapters of its own" (no file holding two); any other status leaves the scan's diff --git a/docs-developers/server/configuration.md b/docs-developers/server/configuration.md index a624b674..cb95d726 100644 --- a/docs-developers/server/configuration.md +++ b/docs-developers/server/configuration.md @@ -198,7 +198,12 @@ The well-known endpoints 404 until the relevant identifiers are set - see Config-declared libraries are **upserted by name** into the database at every startup (`syncLibraries`, roots made absolute) and scanned in the background; libraries can equally be created at runtime through the admin API, in which -case they live only in the database. There is deliberately **no layout key** +case they live only in the database. Because the server never writes a +library to `config.yaml`, a declared library's name and folder can't be changed +at runtime: `PATCH /admin/libraries/{id}` answers `409 config_library` (from +2.4.0; a rename used to come back as a second library at the next start) and +the console locks both fields, while its other settings stay editable. Change +them in `config.yaml` (or the app embedding the server) and restart. There is deliberately **no layout key** - folder shape is auto-detected per folder by the [scanner](scanner.md), with per-folder admin overrides for corrections. diff --git a/docs-developers/server/data-model.md b/docs-developers/server/data-model.md index a7ade71b..84f1e508 100644 --- a/docs-developers/server/data-model.md +++ b/docs-developers/server/data-model.md @@ -171,7 +171,9 @@ alone), and `last_ip` the address of its newest request. Both are overwritten by `auth.ResolveRequest` on every authenticated request, never kept as a history, and the daily retention job blanks `last_ip` on revoked tokens (`auth.ForgetRevokedAddresses`), so an address is kept only while the device is -signed in. +signed in. The same job deletes pairing tokens that can never pair again +(`auth.PrunePairingTokens`: revoked, expired, or linked to an expired or +used-up code). `created_on_token_id` *(0044; FK to `tokens` `ON DELETE SET NULL`, partial index `idx_tokens_created_on`)* records, for a household member's session made on their owner's device (`sign_in_here`, `…/members/{id}/session`), the owner's token it was @@ -1188,7 +1190,12 @@ the 100 most common by key, each named by the spelling most of its books use ### `schema_migrations` Created by `store.migrate` itself (not a migration file): `name` (PK), -`applied_at`. Records which migration files have run. +`applied_at`. Records which migration files have run. After migrating, a server +that finds names here it does not ship (a newer AudioSilo ran on this database: +a rollback) still starts but logs a warning naming them +(in `store.migrate`, the `unknown_migrations` attribute, from 2.4.0): features the newer +server added may not work and their data can be lost, so run the newer version +again or restore a backup made before the upgrade. ## Migration policy: append-only diff --git a/docs-developers/server/media.md b/docs-developers/server/media.md index 769ebeac..883996bb 100644 --- a/docs-developers/server/media.md +++ b/docs-developers/server/media.md @@ -72,11 +72,13 @@ players - iOS `AVPlayer` in particular - then refuse the stream outright | `0xFF` with `b[1] & 0xF6 == 0xF0` | `audio/aac` | Raw ADTS AAC (e.g. per-chapter `.aac` files). Checked **before** the MP3 case because the ADTS sync word also matches the MPEG mask | | `0xFF` with `b[1] & 0xE0 == 0xE0` | `audio/mpeg` | Bare MPEG audio frame sync (untagged MP3) | | `ftyp` at offset 4 | `audio/mp4` | ISO base media: `.m4a`/`.m4b`/`.mp4`, AAC or ALAC inside | + | `1A 45 DF A3` | `audio/x-matroska` | EBML header: Matroska audio (`.mka`), MP3 or another codec inside (2.4.0) | 2. **`audioContentType`** falls back to the extension when the sniff comes up empty: `.m4b`/`.m4a`/`.aax`/`.mp4` → `audio/mp4`, `.mp3` → `audio/mpeg`, `.aac` → `audio/aac`, `.flac` → `audio/flac`, `.ogg`/`.oga` → `audio/ogg`, - `.opus` → `audio/opus`, `.wav`/`.wave` → `audio/wav`. Anything else lets + `.opus` → `audio/opus`, `.wav`/`.wave` → `audio/wav`, `.mka` → + `audio/x-matroska`. Anything else lets `ServeContent` decide. :::warning @@ -91,11 +93,19 @@ The scanner and the `/fs` browse view treat a file as an audiobook when its extension is in `metadata.AudioExtensions` (checked via `metadata.IsAudio`): ``` -.m4b .m4a .mp4 .mp3 .flac .ogg .opus +.m4b .m4a .mp4 .mp3 .flac .ogg .opus .mka ``` `.mp4` is included because audiobooks are sometimes delivered as AAC-in-MP4; -`media` serves it as `audio/mp4`. Audible's DRM formats (`.aax`/`.aaxc`) are +`media` serves it as `audio/mp4`. `.mka` (Matroska audio, often MP3 with its +chapters, as Audiobookshelf reads it) is indexed from 2.4.0 **for the web +player only** (#161): no browser or native player plays the container, so it +is never [direct-playable](#directplayable-when-a-client-should-transcode) and +the web player streams it through `?transcode=1`; a native app lists such a +book but can't play it (a server-side remux is a later follow-up). An `.mka` +beside a file of the same name in another audio format is left out of the +book (`folderAudio`): like an `.aax` beside its `.m4b`, it is that file's source +or copy, and reading both would give the book its chapters twice. Audible's DRM formats (`.aax`/`.aaxc`) are **deliberately excluded**: the server can never stream them (they need per-account decryption), and indexing an `.aax` sitting next to its converted `.m4b` would lump both into one book with duplicated chapters and an @@ -281,8 +291,14 @@ first full pass the runner only reads books whose art is new. ## `DirectPlayable`: when a client should transcode The scanner records each book's audio codec (ffprobe `codec_name`, verbatim) -in `books.codec`. `media.DirectPlayable(codec)` reports whether mainstream -browsers can decode it natively, against this allow-list (`browserCodecs`): +in `books.codec`. `media.DirectPlayable(format, codec)` reports whether mainstream +browsers can play it natively. A **container** no browser plays +(`containerFormats`: `mka`, by the file extension in `books.format`) never is, +whatever the codec inside; for a folder book every file's container counts, not +only the first file's (`Book.PlaysInBrowsers` for `/item` and `/chapters`, and `directPlayableExpr`, +built from `DirectPlayableSQL`, for the admin catalog's `direct_playable` +filter and the Health `transcode` issue). Otherwise +the codec is checked against this allow-list (`browserCodecs`): ``` aac mp3 mp2 flac opus vorbis pcm_s16le diff --git a/docs-developers/server/overview.md b/docs-developers/server/overview.md index 33129a0d..b8b18874 100644 --- a/docs-developers/server/overview.md +++ b/docs-developers/server/overview.md @@ -78,7 +78,7 @@ in-process (see [Configuration](configuration.md#launcher-options-for-embedders) Before the database opens, `Run` applies a restore an admin asked for (`backup.ApplyPendingRestore`); it then starts the backup schedule, the notification workers and the daily retention (sessions, audit log, event -feed). +feed, dead cast and pairing tokens). `resolveTools` here picks the ffmpeg/ffprobe binaries: an explicit path, a copy next to the executable, `$PATH`, and only then a download via `internal/toolfetch`. diff --git a/docs-developers/server/scanner.md b/docs-developers/server/scanner.md index f2b0480b..940534db 100644 --- a/docs-developers/server/scanner.md +++ b/docs-developers/server/scanner.md @@ -33,7 +33,14 @@ The show signature combines the feed's size/mtime with show and `.audiosilo` listings, including audio, CHAP and publication journals. Changes to those files are noticed even with unchanged feed stat. Refresh observes regular file presence, sizes, artwork, barriers and matching CHAP, then atomically updates -the show/episode index. A targeted +the show/episode index. An unchanged signature skips the show only while its +episodes are still indexed: a show with fewer `podcast_episodes` rows than its +`feed.json` listed is indexed again (`PodcastEpisodeRows`, from 2.4.0), since an +older server run on the same database (a rollback to 2.3.1, say) scans the +library as books and drops the episode rows while the show keeps its +signature. Each podcast scan then deletes the books rows that are not episodes +(`PruneNonEpisodeBooks`: what such a books scan left, a folder book at a show +path for one). A targeted `POST /bridge/rescan` forces that refresh for a show path or an indexed episode path belonging to it, returning `{path:, episodes:N}`. It refuses `attachments` mode and `from`; nothing indexable gives `404 not_indexable`. @@ -387,7 +394,10 @@ There is no per-library layout setting. The model matches the dominant - **A directory that directly contains audio is ONE book**, with all those files as its ordered tracks - whether it holds a single `.m4b` or fifty - distinctly-named `.mp3` chapters. Do **not** split a folder's files into + distinctly-named `.mp3` chapters. Audio is what `metadata.AudioExtensions` + lists ([Media](media.md#recognized-audio-metadataaudioextensions)); `.mka` + joins it in 2.4.0, except an `.mka` beside a file of the same name in another + audio format, which `folderAudio` leaves out as that file's source or copy. Do **not** split a folder's files into separate books by filename; that heuristic produced one phantom book per chapter file. - The single exception is the **library root**: loose audio files sitting @@ -400,8 +410,29 @@ There is no per-library layout setting. The model matches the dominant `PUT/DELETE /admin/libraries/{id}/folder-override?path=` (which rescans), and driven by the admin console's folder detection dialog. -`folderBook` orders parts by name (`os.ReadDir`'s stable ordering), sums sizes, -takes the max mtime, and takes the **earliest** file's `added_at`. `addedAt` is +`folderBook` takes its parts in **natural name order** (`sortFileOrder`, from +2.4.0, #160: `naturalLess`, a copy of audiosilo-meta's `scan.NaturalLess` held +to it by `TestNaturalLessIsMetas`, since the Bridge and the community metadata +number a book's files, and so its sidecars' chapters, in that order). Digit +runs compare by value (`2- Part` before `10- Part`, `02` level with `2`), the +rest byte by byte with case folded, so `01 - Prologue` plays before +`01a - Interlude`; names alike that way fall back to their bytes. `audioEntries` +and the discovery walk (`audioDirs`) both sort this way. It sums sizes, +takes the max mtime, and takes the **earliest** file's `added_at`. + +:::note[Behaviour change in 2.4.0] +Before 2.4.0 a folder book's files played in byte order (`os.ReadDir`'s), so +`10-` played before `2-`. A book stored in byte order is re-indexed by the next +scan although no file changed: `Signature.FileOrder` (`catalog.FileOrderKey`, +an FNV-64a of the stored file paths in order) differs from the new order's. +Nothing remaps a listener's place: a whole-book position on such a book can +land elsewhere in the story (offline copies take the new chapters through the +player's `refreshedChapters`). Move detection still finds such a book by the +file byte order put first (`byteFirstFingerprint`, in `detectMoves` and +`IndexMoved`), and a Bridge `chapters.json` made while it played in byte order +still applies, its chapters moved with their files (`movedChapters`, see +[Generated chapters](community-chapters.md#generated-chapters)). +::: `addedAt` is the file's birth (creation) time where the OS records it (`birthtime_darwin.go`/`birthtime_linux.go`), otherwise mtime - a stable chronological key for "recently added" that survives re-indexing. @@ -432,8 +463,8 @@ place, `joinRoot` (the folder or the one holding it, with `book` and a disc set) discs' audio as its files (hidden files and the ignore rules apply as usual): the disc folders in natural order (`discOrder`: `CD2` before `CD10`, `CD 1` equal to `CD1`, ties by path), each disc's files in exactly the order a book - of that disc alone has them (byte-wise name order, not natural), since the - state carry-over maps a disc position as offset plus position. Files and + of that disc alone has them (natural name order, `sortFileOrder`, above), + since the state carry-over maps a disc position as offset plus position. Files and chapters keep their real paths in the disc folders (`file_path`); a part with no chapters of its own is titled with its disc first (`joinedPartTitle`: `CD2 - 01`). The cover is the folder's own image, then the first disc's. @@ -571,10 +602,24 @@ embedded data winning where it is trustworthy: it gives any), then ffprobe (when configured) for duration, chapters, the audio `codec` (`codec_name` of the first audio stream - this is what feeds the `direct_playable` API flag), and richer container tags. + A **series tag** that carries its own position (the series atom, the MP4 + `\xa9grp` grouping, `show` or `TXXX:SERIES`), as Audible-sourced tooling + writes `Discworld, book #40`, is split into the series and its position + (`metadata.SplitSeriesTag`, from 2.4.0, #100): `Series, Book 3`, + `Series Book 3`, `Series #3`, `Series, #3.5`, `Series (Book 3)` and + `Series, Vol. 2` (`book`, `bk`, `vol`, `volume` and `no.` in any case). Only + a trailing, explicitly marked number set off from the name counts, so + `Warhammer 40,000`, `Metro 2033` and `Learning C# 2` keep their whole name. A + tag naming several series (`Discworld #40; Moist von Lipwig #3`) is read by + its first when that one carries a position; a later series-part tag still + overrides the position. 3. **Generic-title guard**: `chooseTitle` keeps the path-derived title when the embedded one is missing or generic - `metadata.IsGenericTitle` flags bare numbers and "Track 01"/"Disc 2"/"CD1"-style labels (token-based, so a real - title like "Part of Your World" is not flagged). + title like "Part of Your World" is not flagged). Letters and numbers in any + script count (from 2.4.0; before, only ASCII ones did), so a title written + in another script names something (`Пролог`, `Глава 1`, `第1章`), and a + number in another script (`٠١`) is still a bare number. The scanner does not record where each value came from. The admin console's per-field provenance works it out when the book is read @@ -904,7 +949,12 @@ granted files still stream. the **unresolved** join - `SafeJoin` returns a symlink-resolved path, and using it would produce `rel_path` keys that disagree with the full scan (which walks the root unresolved) whenever any component of the root is a - symlink (macOS `/tmp → /private/tmp`, NAS mounts). + symlink (macOS `/tmp → /private/tmp`, NAS mounts). The path must also be + spelled **as on disk** (`dirPresent`, from 2.4.0): a case or normalization + variant opens on a case-insensitive filesystem (macOS's and Windows' + defaults), but the scan names a book by the folder listings' spelling, so + indexing the variant would add a duplicate book everyone sees. Such a path + is `ErrNotIndexable`. 2. Classifies the containing directory **exactly as a full scan would** (`booksInDir`, including overrides and the root case; `joinRoot` and `joinedBook` when the path is in a joined folder, walking the folder's diff --git a/docs-developers/server/web-ui.md b/docs-developers/server/web-ui.md index cdaa1222..02d1cbad 100644 --- a/docs-developers/server/web-ui.md +++ b/docs-developers/server/web-ui.md @@ -601,7 +601,7 @@ CSP-sensitive work against a real build served by Go. The console's own gate is (a book with one chapter). The server decides what a title says with `metadata.ChapterTitle`: a filename-shaped title is tidied as the player's `prettifyChapterTitle` tidies it, and a title that names nothing ("024", - "Track 01": `metadata.NamesNothing`, so a title in another script stays) is + "Track 01": `metadata.IsGenericTitle`, which reads letters in every script, so a title in another script stays) is left out; "Chapter 10" or "Part 7" is kept as written. - **Activity > Sessions** (`sessions-page.tsx`) - `GET /admin/sessions` as an infinite query (50 a page, "Show older sessions" passes `next_before`), @@ -940,6 +940,17 @@ still comes from the first-run banner or the [`/setup` page](#first-run-setup-pa It is console-only and admin-only: no capability flag, and the player never asks. +The headless first-run banner (`writeBanner` in `pkg/launcher`) prints the +admin username and password, the auth code and the config file's path, then +sends the reader to this flow and to the connect page (from 2.4.0; before, it +named only the API routes), from the same base URL as `OnURL`: + +``` + Finish setting up: open https://audiosilo.example.com/admin and sign in as admin. + Connect a device: open https://audiosilo.example.com/ and enter the auth code. + (API: POST /api/v1/auth/redeem or /api/v1/auth/login.) +``` + **Where it shows.** The console route `/setup` (so `/admin/setup`) renders as a page of its own, outside `AppShell`: `Root` signs the admin in, then `SignedIn` renders the bare `` on the setup path and the shell diff --git a/docs-users/admin/books.md b/docs-users/admin/books.md index efe4310c..e4508568 100644 --- a/docs-users/admin/books.md +++ b/docs-users/admin/books.md @@ -84,9 +84,25 @@ you pick another sort; a sort you pick stays when you change the filters. ### Changing many books at once -Hover a cover and tick its checkbox to select it (in the table, tick the row, -or the header box to select every loaded book). A bar appears at the bottom of -the screen with the number selected and two actions: +Press **Select** in the row above the list (beside **Filters**) to start picking books, or hover a +cover and tick its checkbox (in the table, tick the row, or the header box to +select every loaded book). While you are selecting, clicking a cover or a row +picks it instead of opening the book, and **Select** reads **Done**: pressing +it ends the selection and clears it. + +- **Shift-click** a cover or a row to select every book from the last one you + clicked to this one, in the order the list shows them, in the grid and the + table alike. Shift-clicking a selected book deselects the run. +- While you are selecting, **Select all *N* books** above the list selects every book shown (it reads + **Select the *N* books shown** while more are still to load). When your + filters match more books than have loaded, **Select all *N* matching books** + loads the rest and selects them all. A bulk action takes up to 1,000 books at + a time, so past that it reads **Select the first 1,000 matching books**, and + the line beside it says "Bulk edits and shares take up to 1,000 books at a + time." + +A bar appears at the bottom of the screen with the number selected and two +actions: - **Edit fields** - set **Author**, **Narrator** or **Series** on every selected book. Only the fields you fill in change; where the books disagree, @@ -106,7 +122,7 @@ the screen with the number selected and two actions: book is added as one of the share's folders, and everyone with the share sees it right away. -**Clear selection** (or Escape) deselects everything. +**Clear selection** (or Escape, or **Done**) deselects everything. ## A book's page @@ -128,7 +144,8 @@ The buttons there are: [Matching with community metadata](#matching-with-community-metadata). - **Listen in player** - opens the book in the web player in a new tab, already signed in as you, so you can check how it sounds or test its - chapters without signing in again. If the browser blocks the new tab, the + chapters without signing in again. The tab signs in with a one-time link + that works for about two minutes. If the browser blocks the new tab, the console says so: allow pop-ups for your server's address and click it again. It only shows when your server has the web player. - **Change cover** - see [Covers](#covers). diff --git a/docs-users/admin/bridge.md b/docs-users/admin/bridge.md index ad6555bb..97ce25c0 100644 --- a/docs-users/admin/bridge.md +++ b/docs-users/admin/bridge.md @@ -10,7 +10,10 @@ safe to put on the internet. A Bridge is optional for listening to your existing books. [Podcasts](podcasts.md) need one and the Podcasts module. Player apps from -2.2.0 [show podcasts](podcasts.md#what-is-available-now); older ones don't. +2.4.0 [show podcasts](podcasts.md#what-is-available-now); older ones don't. +With AudioSilo 2.4.0, use **Bridge 0.3.0 or later**: podcasts, contributions, +generating characters and recaps, and **Detect chapters** need it (see +[Running a Bridge](../getting-started/running-a-bridge.md#which-bridge-version)). :::info[What it does today] The Bridge is new. Today it @@ -35,15 +38,23 @@ page covers the console side: **Server > Settings > Bridge** in the ![Server > Settings > Bridge before a Bridge is connected](/img/screenshots/admin/settings-bridge.png) +With no Bridge paired, the page shows the same compose snippet as the +[setup flow's Bridge step](../getting-started/first-run.md#step-8-add-a-bridge): +the lines to add to the compose file that runs your server, with one volume +for each of your libraries at the folder the server uses (**Copy snippet**), +and a **Running a Bridge** link to this guide for the agent container and the +rest of the options. Under it: "Once the Bridge is running, connect it with a +pairing code." + ## Connecting a Bridge 1. Start the Bridge (see [Running a Bridge](../getting-started/running-a-bridge.md)) and open its own page. 2. In the console, open **Server > Settings > Bridge** and press **Connect a - bridge**. A pairing code appears, with the server's address. + Bridge**. A pairing code appears, with the server's address. 3. On the Bridge's page, paste the **server address** and the **code**, then pair. -![The Connect a bridge dialog with a pairing code](/img/screenshots/admin/bridge-pair.png) +![The Connect a Bridge dialog with a pairing code](/img/screenshots/admin/bridge-pair.png) Instead of pasting, you can give the Bridge the two settings the dialog shows before you start it (**Copy both** copies them): @@ -57,12 +68,16 @@ Good to know: - **The server address** in the dialog is the server's address on your home network when it knows one (an `http://` address if the server uses its own self-signed certificate, which the Bridge can't check), else the address you - opened the console on. If the Bridge runs in a container next to the server, it - may need a different address to reach it, such as `http://audiosilo:8080`: use - whatever works from the Bridge. + opened the console on. A Bridge started from the compose snippet, beside the + server, can use `http://audiosilo:8080` instead, as the line under it says: + use whatever works from the Bridge. When the only address the server knows + is `localhost` or `127.0.0.1`, which a Bridge in another container can't + reach (it would be calling itself), the dialog gives `http://audiosilo:8080` + instead and says why; a Bridge elsewhere needs the server's address on your + network. **Copy both** copies the address the dialog shows. - The dialog closes on its own with **Bridge connected** once the Bridge uses the code. Its details show as soon as it sends its first report, a few seconds later. -- **One Bridge per server.** **Pair a new bridge** makes a code for a replacement: +- **One Bridge per server.** **Pair a new Bridge** makes a code for a replacement: the current Bridge keeps working until the new one uses the code, and then stops. ## Online, offline and its details @@ -82,7 +97,8 @@ lines cover [characters and recaps](generating-characters-and-recaps.md): - **Agent** - the tool the agent container runs (`claude` or `codex`) and whether it is **Ready**, or **None set up**. - **Paid by** - a **Subscription** (the costs are what the same use would cost on - an API key) or an **API key**. + an API key) or an **API key**. It shows once the agent has a credential to pay + with. - **This month** - what the agent has cost this month against the Bridge's monthly cap ("$12.30 of $100"), or "no monthly cap". - **Estimates from** - how many finished books the cost estimates are based on. @@ -130,9 +146,9 @@ done ("3 of 5 required steps done"). Under the steps, the Bridge's accounts, read-only: -- **GitHub account** - the GitHub user signed in on the Bridge's Accounts tab, or - **Not signed in** (shown too when the Bridge shares with a GitHub token from its - environment or the gh command). +- **GitHub account** - the GitHub user signed in on the Bridge's Accounts tab; + **Signed in**, with how (the checklist's line), when the Bridge shares with a + GitHub token from its environment or the gh command; else **Not signed in**. - **Agent credential** - **Set** or **Not set**, as the **Agent credential set** step reads (so **Not set** too while the agent container isn't ready). - **Contributing** - **Ready** when the Bridge can share with the community now, @@ -176,10 +192,12 @@ The **Libraries** card lists every library on the server with what the Bridge fo | **Not reported yet** | the Bridge hasn't checked it yet | Each library shows its folder **On the server**, the folder **On the Bridge**, and -the book it **Checked with**. The Bridge doesn't take a folder's word for it: it +the book it **Checked with** (for a podcast library, the marker file it was +**Found by its marker**). The Bridge doesn't take a folder's word for it: it finds a book the server knows and checks that its files are there with exactly the same sizes, and only then tries writing a small test file (which it removes). -**Verify** below uses the book comparison and is for books libraries. +**Verify** below uses the book comparison, so only books libraries have it: a +podcast library shows no **Verify** button. When a library isn't **Ready**, the line under it says why. The usual reasons: @@ -242,7 +260,10 @@ filters it by state: | **Failed** | it didn't work: the reason is shown under it | | **Cancelled** | you cancelled it | -Each job shows the library (and book), its progress, who asked and when. A finished +Each job shows the library (and book), its progress, who asked and when: "Asked +by" a person, "Scheduled by the server" for the podcast feed checks, episode +downloads and retention checks the server queues itself, or "Asked by a +deleted account". A finished Verify sums up what it found (how many files matched, whether it can write, how long it took); **Details** shows everything it reported. @@ -270,7 +291,7 @@ no podcast follow, search, import or retention controls yet; see [Podcasts](podcasts.md). The Bridge also fetches a missing episode when a compatible client asks to -play it, ahead of waiting downloads. The player (2.2.0 and later) shows +play it, ahead of waiting downloads. The player (2.4.0 and later) shows **Getting this episode** meanwhile. See [Playing an episode that isn't downloaded](podcasts.md#playing-an-episode-that-isnt-downloaded). diff --git a/docs-users/admin/console-tour.md b/docs-users/admin/console-tour.md index fb061e15..9d323341 100644 --- a/docs-users/admin/console-tour.md +++ b/docs-users/admin/console-tour.md @@ -130,7 +130,8 @@ people are listening right now, then: with its count. Click one to open its list, or **Triage** to open [Library health](health.md). It says "Nothing needs attention." when everything is fine. -- **Books per library** - each library's book count as a bar. A count that +- **Books per library** - each books library's book count as a bar (podcast + libraries aren't listed). A count that looks wrong is the cue to check that library's [folder detection](libraries.md#folder-detection). - **Server** - the version (with a "*version* available" link to @@ -146,13 +147,18 @@ notice says "AudioSilo can't read this folder" instead, since there was nothing to keep.) See [When a library folder goes missing](libraries.md#when-a-library-folder-goes-missing). +When saved settings are waiting for a restart, a notice says "Restart AudioSilo +to finish applying your changes" and names them, with **Open Settings** (see +[When a change takes effect](server.md#when-a-change-takes-effect)). + A brand-new server with no libraries shows a welcome card instead, with an **Add your first library** button. After the first-run [Set up your server](../getting-started/first-run.md#set-up-your-server) flow, a [Finish setting up](../getting-started/first-run.md#the-finish-setting-up-card) card at the top of the side column lists the steps you skipped until an admin -presses **Dismiss**. +presses **Dismiss**. While the flow is unfinished, the card offers **Continue +setting up** instead. ### The support card diff --git a/docs-users/admin/health.md b/docs-users/admin/health.md index 54ee61a9..a5ba6648 100644 --- a/docs-users/admin/health.md +++ b/docs-users/admin/health.md @@ -48,7 +48,7 @@ below. | **Files that couldn't be read** | A scan couldn't read one of the book's files: it couldn't be opened (for example a permissions problem), it is empty (0 bytes), or ffprobe couldn't read it (a damaged or incomplete file). The row names the file and the reason. Every scan checks the file again, so once you fix the permissions or the share is back, the book leaves the list by itself. | **Read again** reads the book's files again straight away. A toast says "*title* reads fine now", or "*title* still can't be read" with the reason. | | **Folder may hold several books** | AudioSilo reads a folder with audio in it as one book. These folders look like they hold several: at least two parts, every part an hour or longer, with different titles. | **Choose detection** opens [Library > Folders](libraries.md#the-folders-page) on that folder: **Separate books** splits it, and **Always one book** tells AudioSilo it really is one book, which also clears it from this list. | | **One book split into disc folders** | A book ripped from CDs sits in one folder per disc (`CD1`, `CD2`, ...), so each disc reads as its own book. The row is the first disc, and says "*folder* reads as one book per disc folder". | **Join into one book** sets the folder holding the discs to **Always one book** ("Joining the discs of *folder*"). The library rescans, then they play as one book, with listening progress carried over. See [Books split into disc folders](libraries.md#books-split-into-disc-folders). | -| **Likely duplicates** | Copies of one book in the same library. See [Duplicates](#duplicates). | A side-by-side comparison. | +| **Likely duplicates** | Copies of one book in the same library (and, when you ask, in other libraries). See [Duplicates](#duplicates). | A side-by-side comparison. | | **Missing covers** | No cover image in the book's folder and none inside its files. | **Upload a cover** opens the book's page, where **Change cover** adds one (see [Covers](books.md#covers)). | | **Not matched to community metadata** | The book has no ASIN or ISBN, so community metadata can't find it. Only shown while [community metadata](server.md#community-metadata) is on. | **Review match** opens the book's page with the match dialog open (see [Matching](books.md#matching-with-community-metadata)). To match the whole list at once, use **Match automatically** above it (see [Matching every book at once](#matching-every-book-at-once)). | | **Series spelled several ways** | Books of one series that spell its name differently, for example "He Who Fights With Monsters" on some books and "He Who Fights with Monsters" on others. AudioSilo already shows them as one series; this is only about every book saying the same. See [Series spelled several ways](#series-spelled-several-ways). | **Use this spelling** sets one spelling on every book of the series. | @@ -119,8 +119,11 @@ what it found and apply it. Alice in Wonderland #2"), and the ASIN with its store. A match to the wrong book of a series, or to another series altogether, shows at a glance. 5. **Confident** matches are ticked; untick any you don't want. **To review** - lists matches that scored lower, or only just ahead of another work: tick - the ones that are right, or click **Match by hand** to open that book's + lists matches that scored lower, or only just ahead of another work, and + matches whose recording is another length than the book, however well they + scored. Those say so in both lengths ("Held for review: the book is 10h 34m + long, this recording 7h 2m"), as the closest recording can be + another edition. Tick the ones that are right, or click **Match by hand** to open that book's match dialog over the review. When you accept a match there, you're back on the review where you left it, and the book reads **Matched since this run**. To look closer first, or to listen to it, the icon beside **Match by hand** @@ -164,7 +167,8 @@ Once a marketplace is set, the card also offers **Use *country* ASINs**. It looks again at the books matched earlier, by hand or in bulk, and offers to switch each one to its recording's ASIN in your store, where the community catalogue now has one (it gains them over time). Review and apply it the same -way. It never changes an ASIN from your tags, or one you typed. +way. It never changes an ASIN from your tags, or one you typed, and a book +matched again by hand since the run looked keeps that match. #### Filling in books that are already matched @@ -222,9 +226,15 @@ shows the copy's **Path**, **Format**, **Length**, **Size**, **Chapters**, whether it is **Matched**, how many **Listeners** have progress on it, and when it was **Added**. **Open book** opens a copy's page. -- Only copies **in the same library** are compared. The same book in two - libraries is deliberate (a kids' library, say), and players already show it - once. +- Only copies **in the same library** are compared, unless you turn on + **Include other libraries** above the list (it shows when the server has + more than one books library): "Also group copies of a book that are in + different libraries." Each copy in a group that spans libraries then names + its **Library**. The same book in two libraries is often deliberate (a kids' + library, say), and players already show it once, so those groups never count + towards the number on the **Likely duplicates** card or the Health count, + which stay per library. **They're different books** and **Show ignored** + work on them as on any group. - The discs of a book split into disc folders look alike but aren't copies, so they are listed under **One book split into disc folders** instead, never here. @@ -370,7 +380,7 @@ refreshes itself every 30 seconds. | **ffmpeg** | Found (with its version), so formats browsers can't play are converted while someone listens, and [community chapters](books.md#community-chapters) start on the pause in the audio; "Downloaded into the data folder" when the server fetched its own copy. **Missing**: those formats won't play in the web player, and community chapter starts may be a few seconds off. | | **ffprobe** | Found, so lengths, chapters and codecs are read from the files. **Missing**: lengths and chapters come only from tags. | | **Community metadata** | **Healthy** with how fast the service answered, **Needs attention** when it isn't responding, or **Off** (switched off, or no service address set). The server only asks the service while the lookup is on, at most once a minute. A server that keeps a [local copy](server.md#keeping-a-local-copy) shows the copy here instead: see [below](#the-local-copy-of-community-metadata). | -| **HTTPS certificate** | **Healthy** with the days left and who issued it, **Needs attention** when it expires within two weeks, has expired or its file can't be read, **Waiting** while Let's Encrypt hasn't issued it yet, or **Off** with plain HTTP (a reverse proxy in front handles HTTPS). | +| **HTTPS certificate** | **Healthy** with the days left and who issued it, **Needs attention** when it expires within two weeks, has expired or its file can't be read, **Waiting** while Let's Encrypt hasn't issued it yet, or **Off** with plain HTTP ("For HTTPS, put a reverse proxy in front or switch to Let's Encrypt"). | | **Database** | Its schema number. The book index can be rebuilt from your folders; accounts and progress can't, which is why the server makes [backups](backups.md) of it. | | **Backups** | The backups folder, when the last backup was made and when the next one runs. **Failed** (with the reason) when the last backup failed; **Off** when no backups are scheduled, marked as a problem if there isn't a single backup either. See [Backups and restoring](backups.md). | | ***Each library*** | The library's folder, and the space free on its disk. **Needs attention** when less than a tenth of the disk is free, or when the folder isn't reachable. | diff --git a/docs-users/admin/libraries.md b/docs-users/admin/libraries.md index 5e857432..fbe3e688 100644 --- a/docs-users/admin/libraries.md +++ b/docs-users/admin/libraries.md @@ -10,7 +10,7 @@ per person which ones (or which parts of them) are visible via [shares](sharing.md). [Podcasts](podcasts.md) need a Bridge and the Podcasts module. Player apps -from 2.2.0 [show podcasts](podcasts.md#what-is-available-now); older ones +from 2.4.0 [show podcasts](podcasts.md#what-is-available-now); older ones don't. The book detection, book details and export tools described below apply to **Books** libraries. @@ -59,9 +59,16 @@ A podcast library needs its own folder: it cannot share a root with, contain, or sit inside another library. Mount the same folder read-write in the Bridge; the server can keep reading it read-only. Its card has a **Podcasts** badge, and its menu greys out folder detection and book exports. Its form asks for a -podcast name and folder and hides the book-only settings below. See +podcast name and folder and hides the book-only settings below. It also has +**Give everyone access**, ticked by default, which gives every listener on the +server access to the new library as it is added (see +[Set up a podcast library](podcasts.md#set-up-a-podcast-library)). See [Podcasts](podcasts.md) for downloads, retention and space. +Each library needs a name of its own. A name already in use is refused under +the **Name** field ("That name is already in use. Pick another one."), and so +is the name of a library your `config.yaml` declares, with another folder. + Under them are two optional scan settings, **Scan automatically** and **Skip these files and folders** - see [Scanning on a schedule](#scanning-on-a-schedule) @@ -124,6 +131,14 @@ point it at another folder, or change how it's scanned." - **Book details come from** - see [Where book details come from](#where-book-details-come-from). Books only. +A library declared in the server's `config.yaml` (or by the app that runs the +server) has a **From your config** badge on its card. Its **Name** and +**Folder** are shown but can't be changed here, and the dialog says why: "From +your config: this library's name and folder are set in config.yaml (or by the +app that runs the server). Change them there and restart." Every start reads +those libraries from the config again, so a name changed in the console would +come back as a second library. Its other settings can be changed as usual. + Click **Save changes**. Changing the **folder** or the **skipped files** rescans the library ("AudioSilo is rescanning it now."); renaming it or changing its schedule doesn't, since the books haven't changed. Changing where diff --git a/docs-users/admin/podcasts.md b/docs-users/admin/podcasts.md index e46c7276..eb856c13 100644 --- a/docs-users/admin/podcasts.md +++ b/docs-users/admin/podcasts.md @@ -1,6 +1,6 @@ --- title: "Podcasts" -description: "Podcast libraries, episode downloads and per-show retention through the admin API, podcast search and OPML import/export (in the player and the API), storage limits, private feeds, and which player apps show podcasts (2.2.0 and later)." +description: "Podcast libraries, episode downloads and per-show retention through the admin API, podcast search and OPML import/export (in the player and the API), storage limits, private feeds, and which player apps show podcasts (2.4.0 and later)." --- ## What is available now @@ -10,8 +10,8 @@ Podcasts need a [Bridge](bridge.md) and the **Podcasts** module, enabled in artwork, downloads episodes and removes eligible older downloads. The server reads what the Bridge saves. -:::info[Players 2.2.0 and later show podcasts] -The player shows podcast libraries from **version 2.2.0**: the shows, each +:::info[Players 2.4.0 and later show podcasts] +The AudioSilo app shows podcast libraries from **version 2.4.0**: the shows, each show's episodes, following, a New episodes shelf on Home and playing episodes, including ones the Bridge downloads when someone presses Play. Listeners can also add shows themselves (directory search, a feed address, an OPML list from @@ -35,13 +35,24 @@ External chapter files and transcripts arrive later. ## Set up a podcast library -1. [Run and pair a Bridge](../getting-started/running-a-bridge.md). +1. [Run and pair a Bridge](../getting-started/running-a-bridge.md), version + 0.3.0 or later ([which Bridge version](../getting-started/running-a-bridge.md#which-bridge-version)). 2. In **Library > Libraries**, press **Add library**. Choose **Podcasts** under - **Kind**, give it a name and a folder, then add it. -3. Mount that same folder read-write in the Bridge. The server can keep its + **Kind** and give it a name and a folder. The hint under **Kind** says what + podcasts still need on your server (the Podcasts module, a Bridge that + supports podcasts, or both), or that they are ready. +3. Leave **Give everyone access** ticked (it is by default) so every listener + on the server can follow shows in the new library, then add it. Untick it + to choose who gets it yourself, later, in **People**: until then only + admins see the library. It gives access to each listener's own account; + household members get it through their owner, and the demo account is left + out. If it can't give someone access, a message says so ("Couldn't give 2 + people access") and the library is still added: give them access from + their page in [People](users-and-invites.md#access). +4. Mount that same folder read-write in the Bridge. The server can keep its read-only mount. Check that the library becomes **Ready** on **Server > Settings > Bridge**. -4. Turn on **Podcasts** in **Server > Settings > Modules**. It starts off, and +5. Turn on **Podcasts** in **Server > Settings > Modules**. It starts off, and pairing a Bridge does not turn it on. Its card needs an online Bridge that supports podcasts. @@ -147,7 +158,7 @@ full contract. ### Playing an episode that isn't downloaded -When a compatible client (the player from 2.2.0) asks to play an episode whose +When a compatible client (the player from 2.4.0) asks to play an episode whose audio is missing, the Bridge fetches it and the player shows **Getting this episode** until it is ready. Requests for the same episode share one download, and closing a request does not cancel it. Until the audio is saved, the client is @@ -187,7 +198,7 @@ choosing Archive. An adult with access to the whole podcast library can follow a feed by its URL, in the player's **Add shows** page or through the API. In the player -(2.2.0 and later), anyone but a kid's account or the demo can follow a show +(2.4.0 and later), anyone but a kid's account or the demo can follow a show already in the library from its page, add shows and press **Check for new episodes** on a show they follow, while the module is on and the Bridge is online. Following a visible show already in the library is immediate; diff --git a/docs-users/admin/server.md b/docs-users/admin/server.md index bbe38c7f..dd340cd8 100644 --- a/docs-users/admin/server.md +++ b/docs-users/admin/server.md @@ -54,8 +54,11 @@ metadata is looked up (its **Source**), turning demo mode on or off, how long id - The toast says "Saved. Some of it takes effect when the server restarts." - A notice at the top of every Settings topic, "Restart AudioSilo to finish applying your changes", names the settings waiting, and each carries a - **Waiting for a restart** badge. Until you restart, the server keeps using - its current values. + **Waiting for a restart** badge. The [Overview](console-tour.md#the-overview) shows the + same notice, with **Open Settings**, so a choice made in the + [setup flow](../getting-started/first-run.md#set-up-your-server) isn't left + waiting unnoticed. Until you restart, the server keeps using its current + values. The console can't restart the server for you. Restart it the way you started it: `docker compose restart` (or `docker restart` with your container's name), @@ -376,7 +379,9 @@ see what is wrong. You can turn a module on before its Bridge is ready. Its card then says **On, but hidden from listeners until the Bridge is ready.** Listeners see what the module adds only while the Bridge is ready: it appears and disappears on its own -as the Bridge comes and goes, without you touching the switch. +as the Bridge comes and goes, without you touching the switch. Contributions, +which listeners never see, says **On, but nothing is sent until the Bridge is +ready.** instead. The modules today: @@ -387,7 +392,7 @@ The modules today: also [search podcast directories (no Bridge needed) and import OPML](podcasts.md#finding-shows-and-moving-subscriptions), which listeners use from the player's Add shows page; exporting OPML works with the module on or off. - Player apps from 2.2.0 + Player apps from 2.4.0 [show podcasts](podcasts.md#what-is-available-now); older ones don't. Turning the module off stops follows and feed/download/retention scheduling, keeping the indexed shows and files. Work already held by the Bridge may finish; a diff --git a/docs-users/faq.md b/docs-users/faq.md index 12be7cbf..55fa2b55 100644 --- a/docs-users/faq.md +++ b/docs-users/faq.md @@ -12,12 +12,21 @@ solving, see [Troubleshooting](./troubleshooting.md). The server recognizes these file extensions as audiobooks: -**`.m4b`, `.m4a`, `.mp4`, `.mp3`, `.flac`, `.ogg`, `.opus`** +**`.m4b`, `.m4a`, `.mp4`, `.mp3`, `.flac`, `.ogg`, `.opus`**, and `.mka` +for the web player `.m4b` (a single file with embedded chapters) is the audiobook standard and works best, but a folder of `.mp3` chapter files is treated just as well - the server presents both the same way, with one continuous timeline and a -chapter list. +chapter list. A folder's files play in the natural order of their names, so +`2` comes before `10` whether or not the numbers are padded. + +`.mka` (Matroska audio) plays in the web player only, which the server +converts to MP3 as it streams (it needs ffmpeg). The phone apps list such a +book but may not play it (an iPhone can't), so convert it to `.m4b` or `.mp3` +to listen there. An `.mka` beside a file of the same name in another format is +left out. See +[Supported formats](./getting-started/organizing-your-library.md#supported-formats). Audible's DRM formats (`.aax`/`.aaxc`) are deliberately **not** indexed - the server could never play them, since they're locked to your Audible account. diff --git a/docs-users/getting-started/bridge-accounts.md b/docs-users/getting-started/bridge-accounts.md index 7b93ec81..0e574000 100644 --- a/docs-users/getting-started/bridge-accounts.md +++ b/docs-users/getting-started/bridge-accounts.md @@ -29,6 +29,8 @@ repositories on GitHub, under **your** GitHub account, through a GitHub app call **AudioSilo Contributions**, and GitHub marks each one as filed through that app. You need a GitHub account; a free one is fine. +![The Bridge's Accounts tab: the GitHub card, not signed in yet, and the Agent card](/img/screenshots/bridge/accounts.png) + To sign in: 1. On the Accounts tab, press **Sign in to GitHub**. The card shows a short code diff --git a/docs-users/getting-started/first-run.md b/docs-users/getting-started/first-run.md index 3c61d606..232b350b 100644 --- a/docs-users/getting-started/first-run.md +++ b/docs-users/getting-started/first-run.md @@ -26,9 +26,17 @@ Started normally (including under Docker), the server creates the admin account Admin password : Auth code : Config file : /config.yaml +-------------------------------------------------------- + Finish setting up: open https://localhost:8080/admin and sign in as admin. + Connect a device: open https://localhost:8080/ and enter the auth code. + (API: POST /api/v1/auth/redeem or /api/v1/auth/login.) ======================================================== ``` +The two addresses at the bottom use the server's public address once one is +set; on a new server they are `localhost` with the server's port (`http://` +when HTTPS is off). + Save these now: - The **admin password** signs you in to the admin console at `/admin`, which then [sets up your server](#set-up-your-server) with you (and to the player, if you want to listen as the admin account). @@ -82,7 +90,7 @@ How it works: - **The list on the left** marks each step done (a tick) or skipped (a dashed circle), and any step is a click away. - **Finish later**, at the top, takes you to the console. The flow opens again the next time you open the console in a new browser session, until you finish it, and it picks up at the first step you haven't answered. - **Everything you choose is an ordinary setting.** Each step saves to the same place as [Server > Settings](../admin/server.md#settings) and [Libraries](../admin/libraries.md), so you can change any of it there later. -- **Set by your config.** A setting fixed by an `AUDIOSILO_*` environment variable (or by the desktop app) shows a lock and **Set by your config**, and can't be changed here. +- **Set somewhere else.** A setting fixed by an `AUDIOSILO_*` environment variable shows a lock and which variable sets it ("Set by AUDIOSILO_PUBLIC_URL"), as in Settings; one the desktop app sets says **Managed by the desktop app**. Either can't be changed here. A server that was already in use before this flow existed (it had a library, or more than one account) never shows it. @@ -133,7 +141,7 @@ For a library the server already has (one from your config, say), the step shows Invite links and pairing QR codes are built from the server's addresses, so a wrong one is the usual reason a phone can't connect. - **This browser reaches the server at** shows the address you're using now, with buttons to use it as the **home address** (an address on your home network, like `http://192.168.1.20:8080`) or the **public address** (one that works from anywhere). An address only this computer can use (`localhost`) is offered as neither. -- **Public address** and **Home address** can also be typed. See [Tell the server its public address](./remote-access.md#tell-the-server-its-public-address) and [its home address](./remote-access.md#tell-the-server-its-home-address). +- **Public address** and **Home address** can also be typed. An address without `http://` or `https://` gets one added when you continue (`https://` for the public address, `http://` for the home address, as the examples show). One that still isn't a plain web address is marked under its own field ("Enter a full address, like http://192.168.1.20:8080"), and so is one the server refuses, so you can see which of the two to fix. See [Tell the server its public address](./remote-access.md#tell-the-server-its-public-address) and [its home address](./remote-access.md#tell-the-server-its-home-address). - **A reverse proxy is in front of the server** appears when your requests arrive through one the server doesn't trust yet; **Trust** adds its address to the trusted proxies, so sessions and devices show each person's real address. See [Behind a reverse proxy](./remote-access.md#behind-a-reverse-proxy). - **HTTPS** shows the server's mode and what it means for phones. With the default self-signed certificate, the **plain HTTP at home** switch is here too ([why the apps need it](./remote-access.md#plain-http-at-home-how-the-phone-apps-connect)). These apply after a restart; **Change HTTPS in Settings** opens the rest. @@ -143,7 +151,7 @@ Invite links and pairing QR codes are built from the server's addresses, so a wr If you're moving from Audiobookshelf, AudioSilo can bring each person's listening progress across, so nobody starts their books again. **Open the import** opens [the import](../admin/import.md) in a new tab, leaving the flow where it is. Audiobookshelf's `metadata.json` files in your book folders are read as they are, so their titles, authors, series and descriptions come across with the books. -This step counts as done only once you've opened the import; otherwise it's recorded as skipped, so it waits on the Overview for later. +There is nothing to set here: **Continue** records the step as done, whether or not you opened the import (someone who never used Audiobookshelf has nothing left to do). **Skip** records it as skipped, so it waits on the Overview for later. ### Step 6: Backups and updates @@ -160,6 +168,8 @@ This step counts as done only once you've opened the import; otherwise it's reco Scan the code with your phone's camera: it opens the AudioSilo app, or the web player, signed in as you. The code works once, for ten minutes; **New code** makes another. **Open the web player** opens it in a new tab, also signed in. See [Connecting to a server](../listening/connecting.md). +If you opened the console on the server itself (at `localhost` or `127.0.0.1`), a phone can't use that address. The code then sends your phone to the server's home address instead, and says so ("This page is open on the server itself, so the code sends your phone to the home address, ..."). With no home or public address set, it warns **A phone can't reach 127.0.0.1:8080** (or whichever address you used) instead: **Set the server's address** takes you back to [step 4](#step-4-how-will-people-reach-the-server); add the address there, then make a new code. + Below them: - whether **ffmpeg** is ready: with it, books in formats a browser can't play are converted as they stream; without it, those books won't play in the web player. @@ -169,15 +179,15 @@ Below them: ![Step 8: adding a Bridge, with the compose snippet](/img/screenshots/setup/bridge.png) -Optional. A **Bridge** is a companion container that does the work the server won't: it writes to your library folders and runs heavy jobs like generated character sheets and recaps, so the server itself stays read-only. The step shows the lines to add to the compose file that runs your server (**Copy snippet**), and **Connect a bridge** shows the pairing code to enter on the Bridge's page. Once a Bridge is paired, this step shows its setup checklist instead. [Running a Bridge](./running-a-bridge.md) has the whole story. +Optional. A **Bridge** is a companion container that does the work the server won't: it writes to your library folders and runs heavy jobs like generated character sheets and recaps, and it downloads podcasts, so the server itself stays read-only. The step shows the lines to add to the compose file that runs your server (**Copy snippet**). They mount each of your libraries in the Bridge, one volume per library, at the same folder the server uses, and give the Bridge the server's address in the compose file, `http://audiosilo:8080`. Replace each `/path/to/...` with that library's folder on your computer or NAS. For characters and recaps you also add the agent container: the **Running a Bridge** link opens [Running a Bridge](./running-a-bridge.md), which has it and the rest of the options. **Connect a Bridge** shows the pairing code to enter on the Bridge's page. Once a Bridge is paired, this step shows its setup checklist instead. The same snippet is on **Server > Settings > Bridge** while no Bridge is paired. -**Finish setup** (or **Skip and finish**) ends the flow and takes you to the Overview. Any step you never reached counts as skipped. +**Finish setup** (or **Skip and finish**) ends the flow and takes you to the Overview. Any step you never reached counts as skipped, except the books folder once the server has a books library. If something you chose applies only after a restart (a local copy of community metadata, or an HTTPS setting), the message as you finish names it ("Restart AudioSilo to finish applying ..."), and the Overview keeps a **Restart AudioSilo to finish applying your changes** notice, with **Open Settings**, until the server restarts. ## The Finish setting up card ![The Overview's Finish setting up card, listing two skipped steps](/img/screenshots/setup/finish-card.png) -When you finish with steps skipped, the Overview shows a **Finish setting up** card listing them. Each one opens that step again; once you've been through what's left, you're back on the Overview. **Dismiss** hides the card for good, for every admin of the server. The settings themselves stay where they always are, under [Server > Settings](../admin/server.md#settings) and [Libraries](../admin/libraries.md). +When you finish with steps skipped, the Overview shows a **Finish setting up** card listing them. Each one opens that step again; once you've been through what's left, you're back on the Overview. A skipped books folder step isn't listed once the server has a books library. The card shows too while you haven't finished the flow (you left it with **Finish later**): "You haven't finished setting up this server yet", with **Continue setting up**. **Dismiss** hides the card for good, for every admin of the server. The settings themselves stay where they always are, under [Server > Settings](../admin/server.md#settings) and [Libraries](../admin/libraries.md). ## What lives in the data directory diff --git a/docs-users/getting-started/organizing-your-library.md b/docs-users/getting-started/organizing-your-library.md index 5a9956cf..1d36b1fa 100644 --- a/docs-users/getting-started/organizing-your-library.md +++ b/docs-users/getting-started/organizing-your-library.md @@ -9,7 +9,7 @@ AudioSilo has no import step and no required naming scheme. It reads your books ### One folder = one book -The core rule is simple: **a folder that directly contains audio files is one book**, and all the audio files in it are that book's parts, played in filename order. This matches how most collections are already organized (and how tools like Audiobookshelf expect them): +The core rule is simple: **a folder that directly contains audio files is one book**, and all the audio files in it are that book's parts, played in the natural order of their names: numbers count as numbers, so `2 - The Road.mp3` plays before `10 - The End.mp3`. This matches how most collections are already organized (and how tools like Audiobookshelf expect them): ```text Audiobooks/ ← the library root @@ -24,7 +24,11 @@ Audiobooks/ ← the library root └── Project Hail Mary.m4b ← loose file at the root: its own book ``` -It doesn't matter whether a book folder holds a single `.m4b` or fifty numbered `.mp3` chapter files - the folder is the book. Number multi-file books so they sort correctly (`01`, `02`, … rather than `1`, `2`, … `10`). +It doesn't matter whether a book folder holds a single `.m4b` or fifty numbered `.mp3` chapter files - the folder is the book. Padded numbers (`01`, `02`, …) and plain ones (`1`, `2`, … `10`) both play in the right order. + +:::note[Upgrading from a version before 2.4.0] +Older versions played a book's files in strict character order, so `10` came before `2`. Any book whose files now sort differently is re-read at its next scan and then plays in the new order. Someone partway through such a book may find their place in it has moved. Chapters your [Bridge](../admin/bridge.md) found for the book move with their files. +::: The one exception is the **library root** itself: loose audio files sitting directly in the root are each treated as an individual single-file book, so a simple "flat folder of m4b files" library works with no folders at all. @@ -61,6 +65,7 @@ AudioSilo recognizes these audio file types (upper or lower case): - **MP3** - **FLAC** - **OGG / Opus** +- **MKA** (Matroska audio) - for the **web player**: no browser plays this container, so the server converts it to MP3 as it streams, which needs ffmpeg. The phone apps list such a book but may not play it (an iPhone can't); to listen there, convert it to `.m4b` or `.mp3`. An `.mka` beside a file of the same name in another format (`Book.m4b` and `Book.mka`) is left out, as the other file's source or copy. Anything else in your folders - `.jpg` covers, `.nfo` files, `.pdf` companions - is simply ignored and hidden from browsing, so everything you can click in the player is playable. @@ -73,7 +78,7 @@ Audible's DRM-protected files are deliberately **not** indexed: the server can n For each book, AudioSilo starts with the names on disk, then lets the audio files' embedded tags override them when they're present and meaningful: - **From your folders**: the book's folder (or file) name becomes the title; a leading number like `03 - The Title` is read as its position in a series (but not a number that runs into the name, like `3rd Rock` or `20,000 Leagues Under the Sea`); the parent folder is read as the series and the folder above that as the author. A book with only one folder above it (`Author/Title/`) takes that folder as its author, with no series. So an `Author/Series/03 - Title/` or an `Author/Title/` layout works with no tags at all. -- **From embedded tags**: a title (audiobooks usually carry it in the *album* tag; when the album holds the series name and the *title* tag carries the full "Series: Book" title, the more specific title tag wins), the author (album artist or artist tag), the narrator (composer or narrator tag), the series where a tag exists, and the genres in the *genre* tag (several in one tag are split by `;`, never by a comma, which a store's category such as "Mystery, Thriller & Suspense" holds; a genre that only says the file is an audiobook, such as "Audiobook", "Speech", "Spoken Word" or "Other", is left out). Junk tag titles like "Track 01" are ignored in favour of the folder name. +- **From embedded tags**: a title (audiobooks usually carry it in the *album* tag; when the album holds the series name and the *title* tag carries the full "Series: Book" title, the more specific title tag wins), the author (album artist or artist tag), the narrator (composer or narrator tag), the series where a tag exists (a series tag that ends in a marked number, like the grouping tag "Discworld, book #40", gives the series Discworld and its position, 40; a name that only ends in a number, like "Metro 2033", stays whole), and the genres in the *genre* tag (several in one tag are split by `;`, never by a comma, which a store's category such as "Mystery, Thriller & Suspense" holds; a genre that only says the file is an audiobook, such as "Audiobook", "Speech", "Spoken Word" or "Other", is left out). Junk tag titles like "Track 01" are ignored in favour of the folder name. - **From Audiobookshelf's `metadata.json`**, when the book's folder has one: its title, subtitle, authors, narrators, series, published year, description, publisher, language, abridged, genres, tags, ASIN and ISBN go over the tags. See [Coming from Audiobookshelf](#coming-from-audiobookshelf). - **From ffmpeg's ffprobe**, when available: durations and any chapter list embedded in the file, so a chaptered M4B shows its real chapters in the player. - **From the community database**, for chapters only: a book whose files have no chapters of their own (one long file, or a folder with one chapter per file) gets the community's chapter list once it is [matched](../admin/books.md#matching-with-community-metadata) and the list fits your copy. A book with only broad chapters can be switched to the community's more detailed ones. See [Community chapters](../admin/books.md#community-chapters). diff --git a/docs-users/getting-started/running-a-bridge.md b/docs-users/getting-started/running-a-bridge.md index 66919488..7596318f 100644 --- a/docs-users/getting-started/running-a-bridge.md +++ b/docs-users/getting-started/running-a-bridge.md @@ -23,7 +23,7 @@ With the Podcasts module on, it also fetches missing podcast episodes on play through a compatible client, downloads episodes selected by policy and checks retention by a per-show policy an admin sets through the API for now. See [Podcasts](../admin/podcasts.md#downloads-and-retention) for the default, -protections and the API calls. Player apps from 2.2.0 show podcasts; older +protections and the API calls. Player apps from 2.4.0 show podcasts; older ones don't (see [Podcasts](../listening/podcasts.md)). The book-generation setup uses **two containers**: @@ -37,10 +37,21 @@ The book-generation setup uses **two containers**: can't reach the server or the Bridge. Each job hands it just the text it needs. :::info[Early days] -The Bridge is new and still being built, and its images aren't open to everyone -yet. Read-along and more jobs come in later versions. +The Bridge is new and still being built. Its images are public from version +0.3.0: anyone can pull them, with no sign-in. Read-along and more jobs come in +later versions. ::: +### Which Bridge version + +With AudioSilo server 2.4.0, run **Bridge 0.3.0 or later**. Podcasts, +contributions to the community, **Generate characters and recaps** and +**Detect chapters** need it. The `:latest` tags below follow the newest +release; to stay on one version, use its tag instead, such as +`ghcr.io/kodestar/audiosilo-bridge:v0.3.0`. The console's +[Settings > Bridge](../admin/bridge.md#online-offline-and-its-details) shows the +version of the Bridge you have paired. + ### What you need - A server running in Docker, as in [Quick start with Docker](quickstart-docker.md), @@ -82,6 +93,7 @@ services: volumes: - ./bridge-data:/bridge-data # the Bridge's settings and state - /srv/audiobooks:/library:rw # the SAME folder, at the SAME path, read-write + # One line like it for each other library, a podcast library too. - staging:/staging # the work folder it shares with the agent environment: PUID: "1000" # the owner of your audiobooks (Unraid: 99) @@ -125,7 +137,12 @@ What matters: The server mounts it read-only (`:ro`), the Bridge read-write (`:rw`). With the same path inside both, the Bridge finds each library by itself. (If you mount it somewhere else, tell the Bridge where each library is on its page's - **Libraries** tab.) The agent container never mounts it. + **Libraries** tab.) With several libraries, give the Bridge one volume for + each, every one at the path the server uses. The console writes these lines + for your libraries: the snippet on **Server > Settings > Bridge** (while no + Bridge is paired), or in the setup flow's + [Bridge step](first-run.md#step-8-add-a-bridge), lists one volume per + library. The agent container never mounts them. - **`PUID`/`PGID`** are the user and group the Bridge runs as. Use the **owner of your audiobooks folders**, the same as the server, so anything the Bridge writes belongs to the right user. On generic Linux that is the output of `id -u` and @@ -165,15 +182,33 @@ log: Copy it now. You can change it later on the page's **Settings** tab. +Lost it? Run the Bridge's `reset-password` command as the owner of its data +folder (the `PUID`/`PGID` above). It prints a new password once and signs +everyone out of the page; the pairing, the library mappings and the stored +secrets stay as they are, and a running Bridge uses the new password at once: + +```bash +docker compose exec -u 1000:1000 bridge audiosilo-bridge reset-password +``` + +On Unraid, use `-u 99:100`, with `docker exec` and the container's name in +place of `docker compose exec` and `bridge`. Run as root (without `-u`), the +command refuses and names the user to run it as, so the files it touches stay +the Bridge's own. The Bridge's sign-in page points to this command too. + ### 3. Open the Bridge's page Open `http://:8091` and sign in with that password. The page has tabs for its **Status**, **Libraries**, **Jobs**, **Settings**, **Accounts**, **Workers** and **Logs**. -It only answers from your **local network**, and it always asks for the password, -even on the same machine. It won't open over the internet, through a reverse proxy -or over a VPN address like Tailscale's, and isn't meant to. +![The Bridge's sign-in page: the password field, Sign in, and the line naming the reset-password command for a lost password](/img/screenshots/bridge/sign-in.png) + +It only answers connections that come from your **local network**, and it always +asks for the password, even on the same machine. It won't open from an internet +address or over a VPN address like Tailscale's, and isn't meant to. Don't put it +behind a reverse proxy: the page sees the proxy's own address, which is on your +network, so it would answer whatever the proxy lets through. Until the Bridge is set up, its **Status** tab starts with a **Get set up** card: the steps below, in the same order, each ticked once done, with a word on where @@ -192,10 +227,12 @@ shows the same list on [Settings > Bridge](../admin/bridge.md#the-setup-card). | Review the monthly budget | yes | the **Settings** tab (step 8) | | Sign in to GitHub | no: only to share with the community | the **Accounts** tab (step 9) | +![The Status tab of a new Bridge: the Get set up card, and Pair with a server with the server address and a pairing code filled in](/img/screenshots/bridge/status-unpaired.png) + ### 4. Pair it with your server 1. In the server's admin console, open **Server > Settings > Bridge** and press - **Connect a bridge**. Copy the code. + **Connect a Bridge**. Copy the code. 2. On the Bridge's **Status** tab, under **Pair with a server**, fill in the **Server address** (`http://audiosilo:8080` in the compose file above) and the **Pairing code**, and press **Pair**. @@ -205,7 +242,10 @@ The code works once, for 15 minutes. Instead of pasting, you can add first start; remove it again afterwards. Back in the console the Bridge shows as **Online**. What everything on that page -means is in [The Bridge](../admin/bridge.md). +means is in [The Bridge](../admin/bridge.md). On the Bridge's Status tab, the +pairing form gives way to the server it is paired with. + +![The Status tab once paired: the steps still to do, the Server card with the server's name and address, and This Bridge](/img/screenshots/bridge/status-paired.png) ### 5. Check the libraries @@ -217,6 +257,8 @@ library that isn't ready says why: see same paths (step 1) there is usually nothing to do. If you mounted a library somewhere else, tell the Bridge where it is on the **Libraries** tab. +![The Bridge's Libraries tab: each of the server's libraries with where the Bridge found it and its check](/img/screenshots/bridge/libraries.png) + This step is done when **every** library is ready, since the Bridge saves what it makes in each book's folder. Press **Verify** on a library in the console to check it end to end. @@ -225,8 +267,8 @@ For a **podcast** library, mount its own folder in both containers, read-only for the server and read-write for the Bridge, just as for books. An empty podcast library can be ready: the Bridge writes a small marker in its hidden settings folder and asks the server to read it back. It does not need a book -to compare. Leave the marker in place. **Verify** uses a sample book and is for -books libraries. +to compare. Leave the marker in place. **Verify** uses a sample book, so a +podcast library has no **Verify** button in the console. Give the podcast folder room for downloads; see [Space and availability](../admin/podcasts.md#space-and-availability) for the @@ -385,11 +427,12 @@ month's spend against the cap. On Unraid the Bridge and its agent are **two containers**, set up with the same settings as the compose file above. There are no ready-made Community -Applications templates for them yet (their images aren't public), so add them -with **Add Container**: +Applications templates for them yet, so add them with **Add Container** (the +images are public, so no registry sign-in is needed): -- **The Bridge** (`ghcr.io/kodestar/audiosilo-bridge:latest`): your audiobooks - share mapped read-write at the same path as in the server's container, +- **The Bridge** (`ghcr.io/kodestar/audiosilo-bridge:latest`): each library's + share (your audiobooks, and a podcast share if you have one) mapped + read-write at the same path as in the server's container, `/bridge-data` to an appdata folder, port `8091`, `PUID` `99` and `PGID` `100`, the variables from step 1, and a **work folder** mapped to `/staging`, for example `/mnt/user/appdata/audiosilo-staging`. Keep the work folder outside diff --git a/docs-users/listening/browsing.md b/docs-users/listening/browsing.md index dbb5c7ce..5304a762 100644 --- a/docs-users/listening/browsing.md +++ b/docs-users/listening/browsing.md @@ -73,7 +73,7 @@ The **Library** tab is everything on your server, sorted the way you want to loo ![The Library's Books section: the sections and controls along the top, and the cover grid with progress bars](/img/screenshots/web-player/library.png) -A **podcast library** has just two sections, **Shows** and **Folders**: see [Podcasts](podcasts.md#shows-in-the-library). It appears in the app from version 2.2.0, with a microphone beside its name in the picker. +A **podcast library** has just two sections, **Shows** and **Folders**: see [Podcasts](podcasts.md#shows-in-the-library). It appears in the app from version 2.4.0, with a microphone beside its name in the picker. Every section except Folders shows **one library at a time**. If you have more than one library (or more than one server), a **Library** picker sits with the controls, with your libraries grouped under each server's name; with a single library there's nothing to pick, so it doesn't appear. Your choice is remembered on that device. @@ -86,6 +86,8 @@ Every book in the library, as a cover grid or a list: - **Sort** by **Recently added**, **Title**, **Author** or **Length**. Sorted by title, the books are grouped under letter headings, with an **A to Z** rail for jumping straight to a letter. A leading "The", "A" or "An" is skipped and accented letters file under their plain letter (*Émile* under E), and a book with no title is shown, and sorted, by its folder's name. - **Grid or list**. Your choice is remembered on that device, and the same choice applies to collections and Favourites. On a tablet or computer the list has columns for the narrator, the length and your progress. +![The Genre filter open over Library > Books: each genre with how many books it would show, and Any genre](/img/screenshots/web-player/library-genre.png) + A big library loads in the background, page by page, while you browse; the filters and sorting work on the whole of it once it's in. A cover with no art shows the book's initials instead. diff --git a/docs-users/listening/chromecast.md b/docs-users/listening/chromecast.md index 2671a8fd..ac52ea32 100644 --- a/docs-users/listening/chromecast.md +++ b/docs-users/listening/chromecast.md @@ -22,7 +22,7 @@ It only appears while a book is loaded, and only when that book's server runs an On an iPhone, if AudioSilo hasn't asked before, the first tap brings up a request to use your **local network**: that's how it finds Cast devices. Allow it, or the app can't see any. -Podcast episodes don't cast yet: the Cast button isn't shown while an [episode](podcasts.md) plays. +Podcast episodes don't cast yet: an [episode](podcasts.md) always plays on this device, and the Cast button isn't shown while one plays. If you start an episode while a book is casting, the episode plays on your phone or computer and casting ends, with a note: *"This episode can't be cast"* - *"Podcast episodes can't be cast yet, so this one stays on this device."* ## What you need @@ -63,5 +63,6 @@ Tap the **Cast** button again and choose to stop casting (in the web player, Chr The player says what went wrong, and the book stays on your device: - *"This book can't be cast"* explains why: the server doesn't support casting yet, the app reaches the server at `localhost`, or the book's format can't be played and the server can't convert it. +- *"This episode can't be cast"* means you started a podcast episode while casting: it plays on this device instead. - *"Couldn't play on Living Room TV"* (or *"Couldn't start casting"*) usually means the Cast device couldn't reach your server: check the [address and certificate](#what-you-need) above. - *"Couldn't connect to the Cast device"* means the Cast device itself didn't answer: check it's on and on the same network. diff --git a/docs-users/listening/connecting.md b/docs-users/listening/connecting.md index 785ce350..756360a1 100644 --- a/docs-users/listening/connecting.md +++ b/docs-users/listening/connecting.md @@ -41,7 +41,7 @@ device. If you have the **AudioSilo app** installed, the surest way to sign *it* in with a QR is to scan from inside the app: on the app's connect screen, tap **Scan a QR code** and point the camera at the pairing QR. (This button is in the iOS and Android apps only - a web browser can't scan.) The connect page's **Open in app** button does the same job without a camera. -The QR on the admin console's **invite card** opens an invite web page. Scan that one with your phone's normal camera, or open the invite link in a browser first. The connect page then displays the **pairing QR** the AudioSilo app's scanner accepts. If the app says the QR is not an AudioSilo pairing code, check which of these two codes you are scanning. You can pair an admin account too; a separate listener account is optional. +The app's scanner also takes the QR on the admin console's **invite card**: the app opens that server's sign-in screen and signs in with the invite's code at once, as if you had typed it. (Scanned with your phone's normal camera instead, that QR opens the invite web page.) Each sign-in uses one of the invite's uses. If the app says *"That QR code isn't an AudioSilo pairing or invite code."*, you're scanning some other code. You can pair an admin account too; a separate listener account is optional. ## Connecting from the app @@ -62,9 +62,9 @@ If the server can't be reached, the screen says *"Couldn't reach <address> Instead of an address you can use: - **Scan a QR code** (in the iOS and Android apps) - the pairing QR from the server's connect page or from another of your devices. -- **I have a pairing link** (in the web player) - paste a pairing link your admin or another of your devices sent you (it looks like `https://books.example.com/web/connect?token=…`) and tap **Connect**. +- **I have a pairing link** (in the web player) - paste a pairing link your admin or another of your devices sent you (it looks like `https://books.example.com/web/connect?token=…`), or an invite link (`https://books.example.com/connect#code=…`), and tap **Connect**. -Either signs you in straight away, with nothing to type. A pairing link from an invite keeps working as long as the invite does, for as many devices as it has uses left; one made on another device (or a household member's code) works once, for 10 minutes. One that has expired or been used up says *"This pairing link has expired or was already used. Ask for a new one, or enter your invite code on the sign-in screen."* After a lot of tries in a row, the server asks you to wait: *"Too many tries. Wait a few minutes and try again."* +Either signs you in straight away, with nothing to type. The web player can only connect to the server it came from, so a link for another server says *"This link is for another server, and this page can only connect to its own. Open the link to connect there."*, with **Open the link**. A pairing link from an invite keeps working as long as the invite does, for as many devices as it has uses left; one made on another device (or a household member's code) works once, for 10 minutes. One that has expired or been used up says *"This pairing link has expired or was already used. Ask for a new one, or enter your invite code on the sign-in screen."* After a lot of tries in a row, the server asks you to wait: *"Too many tries. Wait a few minutes and try again."* On a device in [kids mode](kids-mode.md#links-in-kids-mode), a link or QR code isn't opened at all: the app asks for a grown-up to open it. diff --git a/docs-users/listening/in-the-car.md b/docs-users/listening/in-the-car.md index 9fda70b5..8d1194f8 100644 --- a/docs-users/listening/in-the-car.md +++ b/docs-users/listening/in-the-car.md @@ -6,7 +6,7 @@ description: "Listening with Android Auto and Apple CarPlay: getting AudioSilo o The AudioSilo apps for Android and iPhone can put your books on a car's screen: **Android Auto** on Android, **CarPlay** on iPhone. The car shows the same lists on both, plays through the app on your phone, and saves your place exactly as the phone does. The web player has no car mode. :::note -Apple has approved AudioSilo for CarPlay, but the iPhone app in the App Store (version 2.0.0) doesn't include it yet: it arrives with a later update (see [Availability](mobile-apps.md#availability)). Until then, AudioSilo doesn't appear in CarPlay. +CarPlay needs version **2.4.0** or later of the iPhone app (see [Availability](mobile-apps.md#availability)). An older version doesn't appear in CarPlay. ::: ## Getting AudioSilo into the car @@ -24,7 +24,7 @@ If AudioSilo isn't in the launcher: ### CarPlay -Once your iPhone app has the update that brings CarPlay, AudioSilo appears on the CarPlay home screen like any audio app: connect your iPhone to the car and tap it. It works with your iPhone locked in your pocket, as long as you've unlocked it once since it last restarted; until then it waits, and never signs you out. +With version 2.4.0 or later of the iPhone app, AudioSilo appears on the CarPlay home screen like any audio app: connect your iPhone to the car and tap it. It works with your iPhone locked in your pocket, as long as you've unlocked it once since it last restarted; until then it waits, and never signs you out. ## What the car shows diff --git a/docs-users/listening/mobile-apps.md b/docs-users/listening/mobile-apps.md index d475d075..79083408 100644 --- a/docs-users/listening/mobile-apps.md +++ b/docs-users/listening/mobile-apps.md @@ -11,15 +11,15 @@ AudioSilo has native apps for **iOS** and **Android**. They're the same player y - **iOS**: the app is on the **[App Store](https://apps.apple.com/us/app/audiosilo/id6783431375)** - search for AudioSilo, or follow the link. - **Android**: the app is on **[Google Play](https://play.google.com/store/apps/details?id=app.audiosilo)** - search for AudioSilo, or follow the link. -- **CarPlay**: Apple has approved AudioSilo for CarPlay audio, but the App Store version (2.0.0) doesn't include it yet; it arrives with a later update. Until then, AudioSilo doesn't appear in CarPlay. +- **CarPlay**: built into the iPhone app from **version 2.4.0**; see [In the car](in-the-car.md#carplay). An older version doesn't appear in CarPlay, so update the app if you don't see it. - **Android Auto**: built into the Android app; see [In the car](in-the-car.md#android-auto) for getting it onto the car's screen. -- **iPhone widgets and the sleep timer Live Activity**: not in the App Store version yet; they arrive with a later update. -- **Podcasts**: need **version 2.2.0** or later of the app. An older version doesn't show your server's podcast libraries at all, so update it if your server has podcasts and you don't see them. See [Podcasts](podcasts.md). +- **iPhone widgets and the sleep timer Live Activity**: in the iPhone app from **version 2.4.0**; see [Widgets on iPhone](#widgets-on-iphone). +- **Podcasts**: need **version 2.4.0** or later of the app. An older version doesn't show your server's podcast libraries at all, so update it if your server has podcasts and you don't see them. See [Podcasts](podcasts.md). And you don't need the app to listen on your phone: the **web player works great on mobile** and can be [installed to your home screen](offline-downloads.md), downloads included. :::note -This page describes availability at the time of writing; features marked "later update" arrive through the normal store updates. +This page describes availability at the time of writing. New features reach the apps through the normal store updates, so keep the app up to date. ::: ## Connecting the app diff --git a/docs-users/listening/podcasts.md b/docs-users/listening/podcasts.md index 2388e9ca..01d598f4 100644 --- a/docs-users/listening/podcasts.md +++ b/docs-users/listening/podcasts.md @@ -5,7 +5,7 @@ description: "Listening to podcasts in the player: shows in the Library, a show' When your server has a podcast library, the player shows its podcasts beside your books: the shows in the Library, each show's episodes, a **New episodes** shelf on Home for the shows you -follow, and episodes that play like any book. Podcasts need the **AudioSilo app 2.2.0 or later** +follow, and episodes that play like any book. Podcasts need the **AudioSilo app 2.4.0 or later** (the web player comes with your server and is updated with it). An older app doesn't show podcasts at all; update it from the App Store or Google Play. @@ -47,10 +47,17 @@ opens the rest), how many episodes it has, the **Private feed** badge when it is [Check for new episodes](#checking-for-new-episodes). **Last check failed** means the server couldn't read the show's feed the last time it looked; the episodes it already has still play. +A link to a show that leads nowhere (its library is gone, isn't shared with you, or isn't a +podcast library) says *"This link doesn't point to anything here."* + **Episode order.** A show that is told in order (a serial) lists its **oldest episode first**, so you start at the beginning; every other show lists its **newest first**. Switch with **Newest first** / **Oldest first** at the top of the list. The list keeps loading as you scroll. +Episodes go by the date they came out. Episodes from the same day, and shows whose feed gives no +dates at all (a LibriVox reading, say), go by their season and episode numbers, so **E1** to +**E10** of an undated serial list in order. + Each episode row has: - the **title**, then the date it came out, its length, and its season and episode number @@ -63,12 +70,15 @@ Each episode row has: *"Your server can't download episodes right now."* (the same reasons as the [table below](#playing-an-episode)); - one button: **Play** (or **Resume** once you've started), or **Try again** after the server - couldn't get it. While an episode isn't available, **Play** is greyed out; + couldn't get it. While an episode isn't available, **Play** is greyed out with the reason + beside it, on a finished episode too. When the player has no words for the reason, the + button is **Try again** instead, because asking again may work; - a **...** menu with **Add to Up next** and **Mark as finished**. Tap the row itself to open the episode's own page: its cover, title, the show (tap it to go back -to the show), date, length and badges, the play button, its description and, when the episode -file has them, its chapters (**Chapters from the episode file**). When the episode can't be +to the show), date, length and badges, the play button, its description and its chapters under **The whole episode**: the ones in +the episode file (**Chapters from the episode file**), or, for a long episode with no chapter +marks, parts of the length set in Settings (*"This episode has no chapter marks"*). When the episode can't be played right now, the reason sits beside the greyed-out button. An episode page has no Who's who or Story so far: those are for books. @@ -94,14 +104,15 @@ and you'd need the feed's address to follow it again. ## Adding shows -**Add shows**, in a podcast library's Shows, opens a page for adding shows to that library. It -has up to three sections at the top: **Search**, **Feed address** and **Import a list**. Your +**Add shows**, in a podcast library's Shows, opens a page for adding shows to that library. The +page belongs to the Library: **Back** returns there, and a link to the page opens it in the +Library tab. It has up to three sections at the top: **Search**, **Feed address** and **Import a list**. Your server decides which ones you get: **Feed address** is always there, while **Search** and **Import a list** need a server new enough to offer them, and your admin can turn search off. Adding a show needs the same as the Follow button: your admin has turned podcasts on and the server can download episodes right now. Otherwise **Add shows** isn't there, and the page says -*"Adding shows isn't available right now"*. Kids' accounts and the demo can't add shows. To +*"Adding shows isn't available right now"* and to ask the person who runs your server. Kids' accounts and the demo can't add shows. To add a show, you need full access to the library (one shared with you only in part says *"You can add shows only to a library shared with you in full."*). @@ -123,13 +134,15 @@ search. The list says where its results came from, and each result has: directory; - **Follow**. It turns into **Following** with **Open show** when the library already has the show, or **Adding** when the server is fetching it. Once the server is done, **Adding** - turns into **Following** with **Open show**, or into **Couldn't add** with **Follow** back - to try again ([Shows being added](#shows-being-added) says why). + turns into **Following** with **Open show**, or into **Couldn't add** (or **Cancelled**) + with **Follow** back to try again ([Shows being added](#shows-being-added) says why). Searching the same words again shows the same results without asking the directory again. Each person can search about ten times a minute: after that the page says *"You've searched a lot just now. Try again in 20 s."* (with the real wait). **No shows found** means the directory -has nothing under those words; try others, or follow the show by its address. +has nothing under those words; try others, or follow the show by its address. *"This app can't +use podcasts on this server. Update the app, then try again."* means the app is too old for +your server's podcasts. **What the directory sees.** The directory receives the words you searched for (and, for Apple, your device's country), and sees your server's address, not yours: the search and the @@ -153,7 +166,10 @@ only you see the show, and it's [never exported](#exporting-your-shows). Many pr addresses are recognised as private by themselves; the switch is for the ones that aren't. If the library already has the show, the page says *"You follow The Slow Kitchen."* with -**Open show**. Otherwise it says *"Adding the show. It appears in Shows once it's ready."* +**Open show**. Otherwise it says *"Adding the show. It appears in Shows once it's ready."* When +you turned on **Private feed** but the library already has that feed as a public show, you +follow it as it is: *"You follow The Slow Kitchen. It was already in this library as a public +feed, so it isn't private."* When it can't follow the address, the page says why: @@ -177,7 +193,12 @@ feed's address (shortened, so a private address isn't on screen in full) and how |---|---| | **Couldn't reach the feed.** | The show's host didn't answer. Check the address, or try again later. | | **That address isn't a podcast feed.** | It's a web page or another kind of file; find the show's RSS feed address. | +| **Nothing was found at that address.** | Check the address for a typo. If it's right, the show may have moved. | | **The feed isn't there any more.** | The show moved or ended. Look for its new address. | +| **The feed refused the request. It may need a login.** | A members-only feed needs the private address made for you; find it on the show's site. | +| **That address is on a local network. The person who runs your server can allow it.** | The feed is on a home or office network, which your server doesn't fetch from unless your admin allows that network. | +| **That feed is too large.** | The feed file is bigger than your server accepts. | +| **The feed's server answered with an error. Try again later.** | The show's host had a problem. | | **Follow the feed again to finish adding it.** | The server restarted before it got to it. Follow the address (or import the list) again. | | **Something went wrong adding it.** | Try again later, or ask your admin. | @@ -199,19 +220,35 @@ per show in the list, by its place in the file (**Entry 4**, say): |---|---| | **Following** | The library had the show, so you follow it now. | | **Already following** | You followed it already. | -| **Being added** | The server is fetching it (see [Shows being added](#shows-being-added)). It turns into **Following** once added, or **Couldn't add**. | +| **Being added** | The server is fetching it (see [Shows being added](#shows-being-added)). It turns into **Following** once added, or **Couldn't add** or **Cancelled**. | | **Waiting for the next batch** | Still to come (see below). | | **In the list twice** | The same feed appears earlier in the file (its entry number is in brackets). | | **No usable feed address** | The entry has no address the server can use. | | **Can't be added right now** | The server can't fetch new shows at the moment; ask your admin. | -| **Couldn't add** | Something else went wrong with this one. | +| **Couldn't add** | The server couldn't add this one. A line under it says why, as in [Shows being added](#shows-being-added). *"It couldn't be added a short while ago."* means the same feed failed to add in the last hour. | +| **Cancelled** | Adding it was stopped before it finished. | **Long lists go in batches of five.** Your server adds at most five new shows for you at a time, so the rest of a long list waits. The player sends the next batch by itself once the shows being added are done and at least half a minute has passed: the page says *"Waiting for the shows being added before the next batch."* or *"Next batch in 12 s."* Once the last batch is sent it says *"The last shows are still being added."* until they're in, then **All done.** -Shows the library already has don't wait for a batch. +Shows the library already has don't wait for a batch. When your server limits how often +imports run, the page says *"Imports can run about once every 30 seconds. Trying again in 12 +s."* and goes on by itself. + +**A feed that failed recently isn't tried again by the import.** If you tried to add a feed +in the list to this library in the last hour and it couldn't be added, the import marks it **Couldn't add** straight away and +doesn't send it to your server again, so a few dead feeds can't hold up the rest of the list. +Once no entry is waiting for a batch, the import sends nothing more. When it finishes with +entries that couldn't be added, it says *"Done. 2 shows couldn't be added. You can try each one +again below."* in place of **All done.** + +**Try again on an entry.** An entry that says **Couldn't add** or **Cancelled** has its own +**Try again**, which follows that one feed by its address, the same as +[Feed address](#following-a-feed-by-its-address). That always asks your server again, even +for a feed that failed a moment ago. The entry then reads **Following** or **Being added**; +if the follow is refused, a note says why. When some entries **Can't be added right now** (the part of your server that fetches podcasts is offline, say), the import doesn't keep retrying them by itself: the page says *"2 shows can't @@ -285,6 +322,11 @@ episode"*. **Whatever you were listening to keeps playing** until the episode is ready; then the episode takes over. - If the server can't get it, the strip shows the reason in place of the progress. +- On an iPhone with iOS 26, the player above the tab bar shows the episode on its way, with + **Try again** and **Cancel**, even when nothing else is playing. With a book playing, its + second line says how the episode is getting on. +- If your server stops answering for a moment while it gets the episode, the player checks + back less and less often (at most once a minute) until it answers again. - **Cancel** stops waiting in the player. The server keeps downloading, so the episode will be **Ready on your server** next time. - Playing something else while you wait also stops waiting for it. @@ -314,7 +356,14 @@ and finished mark stay exactly where they were, so you may land a little early o An episode never opens the [end credits](end-of-book.md). It leaves your Up next, and with **auto-play** on (and the sleep timer not running) the next item in [Up next](up-next.md) -starts; otherwise playback stops. An episode never plays on into a series or folder. +starts; otherwise playback stops. An episode never plays on into a series or folder, or into +the show's next episode by itself. + +To hear the show's episodes one after another, put them in Up next. While an episode plays, the +suggestions under Up next offer the episode that comes after it in the show (**Next episode**, +in the show's order from oldest to newest, whichever way you list the show), and **+** adds it. +While an episode is loaded, Up next's switch reads **Play Up next automatically**: *"When this +episode ends, the first item in Up next plays."* ## Private feeds @@ -332,4 +381,7 @@ collection you share with a child. A kid's account can't follow shows. See - **Offline downloads.** Episodes can't be downloaded to your device, and automatic downloads and keeping the next books ready skip them. They stream from your server. -- **Casting.** The Cast button doesn't appear while an episode plays. +- **Casting.** Episodes play on this device, never on a Cast device: the Cast button doesn't + appear while an episode plays. Start an episode while a book is casting and the episode plays + here and casting ends, with *"This episode can't be cast"*. See + [Chromecast](chromecast.md). diff --git a/docs-users/listening/recaps-and-characters.md b/docs-users/listening/recaps-and-characters.md index 46b5f5f9..8c844d29 100644 --- a/docs-users/listening/recaps-and-characters.md +++ b/docs-users/listening/recaps-and-characters.md @@ -14,6 +14,8 @@ Under each, a line credits the people who wrote them and links the open licence For a book the community hasn't covered, your server's admin can have the server's [Bridge](../admin/bridge.md) write characters and recaps itself (see [Generating characters and recaps](../admin/generating-characters-and-recaps.md)). They appear in the same tabs and follow the same spoiler rules, with a small **Generated on this server** mark at the top of the tab and no community credit line. A book doesn't need to be matched for them to appear. +![A book's Recaps tab early in the book, with recaps generated on the server: the Generated on this server mark, the Whole-book summary kept closed as a spoiler, and the line saying how many recaps are hidden to avoid spoilers, with Show anyway](/img/screenshots/web-player/book-recaps-generated.png) + The community's material always comes first: when the community has characters but no recaps for a book, you see the community's characters and the generated recaps, each tab marked for what it holds. When the community has only the book's whole-book summary, its summary stays beside the generated chapter recaps, and the Recaps tab marks each for where it came from, with the community's credit line for its summary. An app version from before this feature shows generated material only for a book matched with community metadata, without the mark. The Bridge also writes a short, spoiler-free description of the book, which the book page's [About card](book-page.md#about-this-book) shows, with the same mark, when nothing else describes the book. diff --git a/docs-users/listening/up-next.md b/docs-users/listening/up-next.md index fe59872e..89864aea 100644 --- a/docs-users/listening/up-next.md +++ b/docs-users/listening/up-next.md @@ -5,7 +5,7 @@ description: "Lining up the books you want to hear next: opening Up next on a co **Up next** is your queue: the books you'd like to hear after the one you're on, in the order you want them. It's kept on your server, so the same list follows you to every device you listen on. -[Podcast episodes](podcasts.md) can go in Up next too, from an episode's **...** menu. An app older than 2.2.0 doesn't show them in the list, but changing the list there keeps them on it. +[Podcast episodes](podcasts.md) can go in Up next too, from an episode's **...** menu. An app older than 2.4.0 doesn't show them in the list, but changing the list there keeps them on it. :::note Up next needs a recent AudioSilo server. On an older server the Up next buttons don't appear at all, and everything else works as before. @@ -38,6 +38,8 @@ Up next shows **one server's queue**: the server of the book you're playing, or - **Continue the series and more** underneath: the next book in the series you're playing, books you're partway through on this server, and, when the community database knows the next book in a series but your server doesn't have it, a pale "ghost" for it with **See the series**. Tap **+** to add a suggestion to the queue. - **Play the next book automatically** at the bottom: when a book ends, start the first book in Up next, or the next in the series. This is the same switch as **Automatically play next book** in [Settings](settings.md#up-next-and-downloads) - see [The end of a book](end-of-book.md#playing-on-automatically). +While a [podcast episode](podcasts.md#when-an-episode-ends) is playing, the suggestions offer the show's next episode, marked **Next episode**, and the switch reads **Play Up next automatically**: after an episode, only the first item in Up next plays, never the next episode unless you've queued it. + ## Adding books - Open any book's [menu](browsing.md#the-book-menu) (its **...** button in a list, or press and hold its cover) and choose **Add to Up next** (or **Remove from Up next** if it's already there). @@ -60,4 +62,4 @@ A short note confirms each change with **Undo**, in case you tapped the wrong bo ## When a book ends -The first book in your queue is what plays next; see [What plays next](end-of-book.md#what-plays-next). A book leaves the queue when you play it from Up next, when it starts as the next book at the end of another, and when it finishes or you use **Mark as Finished** in the full player. Started some other way, or marked finished from its book menu, it stays in the queue until you remove it. With **Keep the next books ready** on, AudioSilo also [downloads the top of your queue](offline-downloads.md#keep-the-next-books-ready) ahead of time. +The first book in your queue is what plays next; see [What plays next](end-of-book.md#what-plays-next). A book leaves the queue as soon as it starts playing, wherever you start it: from Up next, its page, a list, the car, or as the next book at the end of another. It also leaves when it finishes or you use **Mark as Finished** in the full player. Marked finished from its book menu, it stays in the queue until you remove it. With **Keep the next books ready** on, AudioSilo also [downloads the top of your queue](offline-downloads.md#keep-the-next-books-ready) ahead of time. diff --git a/docs-users/troubleshooting.md b/docs-users/troubleshooting.md index 6cdd3f50..249244d1 100644 --- a/docs-users/troubleshooting.md +++ b/docs-users/troubleshooting.md @@ -102,6 +102,23 @@ overrides**. Move the database files aside rather than deleting them, so you can restore them if you change your mind. ::: +### I went back to an older server version + +Each update can add to the database, and an older server doesn't know what a +newer one added. It still starts, and from 2.4.0 on its log warns about it: +"this database was used by a newer AudioSilo server: features it added may not +work here and their data can be lost; run the newer version again, or restore +a backup made before the upgrade". (Versions before 2.4.0 start without the +warning.) Run the newer version again as soon as you can, or +[restore a backup](./admin/backups.md#restoring-a-backup) made before the +update if you mean to stay on the older one. + +Podcasts are the part an older server gets wrong: a version before 2.4.0 +reads a podcast library as books, and drops its episodes from the index. +Back on 2.4.0 or later, the next scan of the podcast library indexes them +again from the details the Bridge saved; nobody's listening progress or +bookmarks are lost, since they belong to each episode's file. + ## Connecting ### My invite code says "invalid or expired auth code" @@ -197,6 +214,14 @@ If such a book still won't play in the browser: and let the server rescan (your progress survives as long as the path stays the same). +### A book plays in the browser but not in the app + +A book in `.mka` (Matroska audio) files plays only in the web player: the +server converts it as it streams, which the apps can't ask for, and an iPhone +can't play the format at all. Convert the files to `.m4b` or `.mp3` and let +the server rescan (see +[Supported formats](./getting-started/organizing-your-library.md#supported-formats)). + ### Playback stops with an error mid-book The player watches every playback attempt: if the audio doesn't start (or @@ -224,7 +249,9 @@ row and page before you press it: *"Your server can't download episodes right now."* also appears when the server is too old to fetch episodes on demand. If you don't see your server's podcasts at all, check your app's version first: -an app older than 2.2.0 shows no podcasts, so update it. If it is up to date, +an app older than 2.4.0 shows no podcasts, so update it. An older app can't +see podcast episodes in **Up next** either, so clearing Up next there leaves +the episodes queued for your up-to-date devices. If it is up to date, ask your admin whether you have access to the podcast library (and whether the server itself is up to date). Kids mode never shows podcasts, and a [private show](./listening/podcasts.md#private-feeds) appears only to the @@ -252,7 +279,7 @@ progress on the server is intact. ### AudioSilo doesn't appear in Android Auto or CarPlay Android Auto only lists apps installed from Google Play unless you allow -unknown sources, and CarPlay arrives with an iPhone app update after 2.0.0 (see +unknown sources, and CarPlay needs version 2.4.0 or later of the iPhone app (see [Availability](./listening/mobile-apps.md#availability)). See [Getting AudioSilo into the car](./listening/in-the-car.md#getting-audiosilo-into-the-car) for the steps and checks. diff --git a/screenshots/README.md b/screenshots/README.md index b91adce4..e9a713b0 100644 --- a/screenshots/README.md +++ b/screenshots/README.md @@ -37,6 +37,17 @@ that use the exact English labels from the server's the same rename in the script (a failed step is logged and its shot falls back to the previous image or a placeholder). +No shot shows this machine's folders. `capture-admin.mjs` and +`capture-setup.mjs` route every `/api/` answer through `showDockerPaths` in +`lib.mjs`, which swaps the capture paths for the ones a Docker install has: +the seeded books at `/library`, the Inbox at `/inbox`, podcasts at +`/podcasts`, the data folder at `/data`, the web player at `/app/web` (from +`WEB_DIR`, which `run.sh` passes) and anything else under `.cache` at `/srv`. +It also reports the host as `linux/amd64`. A folder the scripts type, or the +console sends, in its shown spelling goes to the server as the real path, so +the folder pickers and checks still work. A new capture path needs a pair in +that script's `SHOWN` list. + The first-run setup shots (`server/setup-wizard*.png` and `setup/*.png`) come from `capture-setup.mjs`, which `run.sh` runs after `capture-admin.mjs` against the `--setup` instance (a fresh data dir, no admin yet, started only @@ -51,7 +62,9 @@ Because a fresh server sends its admin to that flow on the first console visit of every browser session, `capture-admin.mjs` marks the demo server (and the mirror-mode one) set up (`PUT /admin/setup`, finished and dismissed) before it opens a browser, so its shots never land on the flow and the Overview shows no -setup card. +setup card. Only a 404 (a server from before the flow) is let pass there; any +other failure stops the run. `run.sh` waits for the `--setup` instance to answer +`/healthz` before it reads the setup link from its log. Two admin shots depend on the server's community metadata lookup, which the demo server leaves at its default (`https://meta.audiosilo.app`, so it needs @@ -369,9 +382,13 @@ of them the seeded The Slow Kitchen; `seed-podcasts.mjs` draws their artwork int by address answers 200 following for a seeded show's feed and 202 with a new request for any other; `/me/podcasts` is the real answer with the pass's made-up follow requests (one being added, one that failed); and the OPML import answers one first wave (two -following, five being added, a duplicate, an invalid entry and three waiting) while its -five requests stay open, so the page holds on its "Waiting for the shows being added" -line. Nothing is followed on the server and no request leaves the machine. +following, five being added, a duplicate, an invalid entry, one passed over because +its follow failed within the hour (`recently_failed`, naming that failed request, so it +shows that request's reason) and three waiting) while its five requests stay open, so +the page holds on its "Waiting for the shows being added" line. The import and podcast +show shots grow the viewport to the end of the list (`fitViewportTo` in `lib.mjs`), and +the search shots wait for the directory artwork to load (`imagesLoaded`). Nothing is +followed on the server and no request leaves the machine. The connect shots run last, in a fresh browser with nothing stored: `web-player/connect.png` types the server's own address and presses Continue (the probe @@ -431,6 +448,36 @@ and pause steps always run, so a partial run stages its shots as a full one does 4. Run `./run.sh` (or at minimum `node placeholders.mjs`) so the file exists - the Docusaurus build fails on missing images by design. +## The Bridge's page + +The `bridge/` shots (`sign-in.png`, `status-unpaired.png`, `status-paired.png`, +`libraries.png`, `accounts.png`) come from `capture-bridge.mjs`, which `run.sh` +runs after the setup captures when the `BRIDGE` checkout (default the sibling +`audiosilo-bridge`, a private repo) is there; without it, or with +`SKIP_BRIDGE=1`, the shots keep their previous images. `run.sh` syncs the +Bridge's page into that checkout (`scripts/build-web.sh --sync-only`, built and +gitignored there), builds the binary into `.cache/audiosilo-bridge` stamped with +`BRIDGE_VERSION` (default the checkout's `git describe`; a release run sets the +release's, such as `BRIDGE_VERSION=0.3.0`), and starts it on `:8793` +(`SHOTS_BRIDGE_PORT`) with a fresh data dir, a `HOME` of its own and only the +system's `PATH`, as a fresh container has: this machine's `gh` sign-in and +speech-recognition tools stay out of the shots. It reads the one-time password +from the Bridge's log. + +The script signs in through the page, takes a pairing code from the demo +server's admin API (`POST /admin/bridge/pair`, which replaces the stand-in +Bridge `capture-admin.mjs` paired) and pairs through the page's own form. The +Bridge then finds the server's libraries at the server's paths, since it runs on +the same machine. The page's `/api/status`, `/api/libraries` and `/api/engine` +answers are rewritten on the way into the browser (a Playwright route) so the +shots show what a reader's container would: the library folders as +`/library` (as in the compose examples) and `/inbox` rather than this machine's `.cache` paths, the server +as `http://audiosilo:8080` (the address the shot's Pair form shows too), and +whisper.cpp on the CPU for speech recognition, which a Linux image picks where +a Mac picks mlx-whisper. At the end +it unpairs on both sides. The agent container never +runs, so the Agent card and the setup checklist say it is not running. + ## AudioSilo Meta site captures The `meta/` shots come from `capture-meta.mjs`, which drives the diff --git a/screenshots/capture-admin.mjs b/screenshots/capture-admin.mjs index 5fa85e33..5aff41e7 100644 --- a/screenshots/capture-admin.mjs +++ b/screenshots/capture-admin.mjs @@ -47,11 +47,13 @@ import { fitViewportToPage, isoAgo, pathQuery, + showDockerPaths, silence, sleep, shoot, step, submitSignIn, + waitForScans, wanted, DESKTOP_CONTEXT, } from './lib.mjs'; @@ -62,6 +64,23 @@ const PASSWORD = process.env.ADMIN_PASSWORD; const MIRROR_ORIGIN = (process.env.MIRROR_ORIGIN || '').replace(/\/$/, ''); const MIRROR_PASSWORD = process.env.MIRROR_PASSWORD || ''; const INBOX_DIR = path.resolve(process.env.INBOX_DIR || path.join(CACHE, 'inbox')); +const WEB_DIR = process.env.WEB_DIR ? path.resolve(process.env.WEB_DIR) : ''; +// The console shows this machine's folders as a Docker install has them (lib.mjs +// showDockerPaths); anything else under .cache reads as under /srv. +const SHOWN = [ + [path.join(CACHE, 'library'), '/library'], + [path.join(CACHE, 'podcasts'), '/podcasts'], + [INBOX_DIR, '/inbox'], + [path.join(CACHE, 'data'), '/data'], + [path.join(CACHE, 'mirror-data'), '/data'], + ...(WEB_DIR ? [[WEB_DIR, '/app/web']] : []), + [CACHE, '/srv'], +]; +const SHOWN_HOST = [['"os":"darwin"', '"os":"linux"'], ['"arch":"arm64"', '"arch":"amd64"']]; +const shownPath = (real) => { + const hit = SHOWN.find(([r]) => real === r || real.startsWith(`${r}/`)); + return hit ? hit[1] + real.slice(hit[0].length) : real; +}; if (!PASSWORD) { console.error('capture-admin: ADMIN_PASSWORD is required'); process.exit(1); @@ -70,18 +89,6 @@ if (!PASSWORD) { // ── Provision demo state via the API ──────────────────────────────────────── const api = apiClient(ORIGIN); -// Waits until no library scans or waits in the job queue (scans run one at a time). -const waitForScans = async (token, timeoutMs = 90000) => { - const until = Date.now() + timeoutMs; - await sleep(500); - while (Date.now() < until) { - const libs = (await api(token, 'GET', '/admin/libraries'))?.libraries ?? []; - if (!libs.some((l) => l.scan?.running || l.scan?.queued)) return; - await sleep(500); - } - throw new Error('scans still running after the timeout'); -}; - let inviteCode = ''; let samId = null; let library = null; // the seeded library ({id, name, root, ...}) @@ -97,10 +104,13 @@ console.log(' ✓ admin login'); // (capture-setup.mjs), so this server is marked set up. Finished and dismissed, // as an upgraded server is, so the Overview shows no "Finish setting up" card. // (A server from before the flow has no such route.) +// Only a 404 (no such route) is let pass: any other failure stops the run, since +// every console shot would then show the setup flow instead. const markSetUp = (client, tok) => - client(tok, 'PUT', '/admin/setup', {finished: true, dismissed: true}).catch((e) => - console.log(` ! first-run setup not marked done: ${e.message}`), - ); + client(tok, 'PUT', '/admin/setup', {finished: true, dismissed: true}).catch((e) => { + if (e.status !== 404) throw e; + console.log(' ! this server has no first-run setup to mark done'); + }); await markSetUp(api, token); await step('provision listener + share', async () => { @@ -184,7 +194,7 @@ await step('provision scan settings', async () => { scan_schedule: 'every:6h', ignore_patterns: ['# Publisher samples and bonus material', '*.sample.mp3', 'Extras/'], }); - await waitForScans(token); + await waitForScans(api, token); console.log(' ✓ provisioned (schedule + skip rules on the seeded library)'); }); @@ -404,6 +414,7 @@ await step('provision listening sessions', async () => { // ── Capture ──────────────────────────────────────────────────────────────── const browser = await chromium.launch(); const ctx = await browser.newContext(DESKTOP_CONTEXT); +await showDockerPaths(ctx, SHOWN, SHOWN_HOST); const page = await ctx.newPage(); // Opens a console route and waits for it to render: by default its page @@ -551,7 +562,7 @@ await step('add library with the folder picker', async () => { await dialog.waitFor({timeout: 8000}); // Start the picker in the seeded library's folder, so it lists its author // folders rather than the capture machine's filesystem root. - if (library?.root) await dialog.getByLabel('Folder', {exact: true}).fill(library.root); + if (library?.root) await dialog.getByLabel('Folder', {exact: true}).fill(shownPath(library.root)); await dialog.getByRole('button', {name: 'Browse', exact: true}).click(); await dialog.getByRole('list', {name: 'Folders on the server'}).waitFor({timeout: 8000}); // Choosing a folder fills the empty Name field from the folder's name. @@ -735,7 +746,7 @@ await step('provision an Inbox library with issues', async () => { ignore_patterns: ['# Bonus material', 'Extras/', '*.sample.mp3'], }); inbox = created?.library ?? created; - await waitForScans(token); + await waitForScans(api, token); // One ignored book, so the cards and the list show "Show ignored". const folk = (await api(token, 'GET', `/admin/books?library_id=${inbox.id}&issue=no_cover&limit=50`)) ?.books?.find((b) => b.path.startsWith('Folk Tales')); @@ -863,6 +874,7 @@ await step('health: system in mirror mode', async () => { } } const mctx = await browser.newContext(DESKTOP_CONTEXT); + await showDockerPaths(mctx, SHOWN, SHOWN_HOST); try { const mp = await mctx.newPage(); await open(mp, '/', (pg) => pg.getByLabel('Username', {exact: true}), MIRROR_ORIGIN); @@ -1064,7 +1076,7 @@ await step('settings: bridge', async () => { if (!info?.capabilities || !('bridge' in info.capabilities)) throw new Error('this server build has no Bridge'); try { await open(page, '/server?topic=bridge'); - const connect = page.getByRole('button', {name: 'Connect a bridge', exact: true}); + const connect = page.getByRole('button', {name: /^Connect a bridge$/i}); await connect.waitFor({timeout: 15000}); await sleep(800); await shoot(page, 'admin/settings-bridge.png'); @@ -1072,8 +1084,8 @@ await step('settings: bridge', async () => { // The code is shown once, in the dialog: the stand-in reads it from the // environment-variable block, as someone copying both would. await connect.click(); - const dialog = page.getByRole('dialog', {name: 'Connect a bridge'}); - await dialog.getByText('Waiting for the bridge...').waitFor({timeout: 8000}); + const dialog = page.getByRole('dialog', {name: /^Connect a bridge$/i}); + await dialog.getByText(/^Waiting for the bridge\.\.\.$/i).waitFor({timeout: 8000}); await sleep(800); await shoot(page, 'admin/bridge-pair.png'); const env = await dialog.locator('pre').innerText(); diff --git a/screenshots/capture-bridge.mjs b/screenshots/capture-bridge.mjs new file mode 100644 index 00000000..45c0b610 --- /dev/null +++ b/screenshots/capture-bridge.mjs @@ -0,0 +1,148 @@ +// Captures the Bridge's own page (the LAN page an AudioSilo Bridge serves) for the +// User Guide: its sign-in, the Status tab before and after pairing, the Libraries tab +// and the Accounts tab. +// +// run.sh starts a real Bridge (the BRIDGE checkout, built with its page embedded) on a +// fresh data dir after the other captures and hands this script its one-time password. +// The Bridge is paired with the demo server for real: a pairing code from the admin API +// (POST /admin/bridge/pair; it replaces the stand-in capture-admin.mjs paired, which is +// no longer needed by then), typed into the page's Pair form. Its first heartbeat lists +// the server's libraries, which it finds at the server's own paths (it runs on the same +// machine), as a container given one volume per library at the same paths does. +// The Bridge is unpaired again at the end (both sides). +// +// Env: BRIDGE_BASE (the page, http://127.0.0.1:/), BRIDGE_PASSWORD (its +// one-time password), AS_ORIGIN + ADMIN_PASSWORD (the demo server). +import {chromium} from 'playwright'; +import path from 'node:path'; +import {CACHE, DESKTOP_CONTEXT, apiClient, fitViewportToPage, fitted, shoot, sleep, step, wanted} from './lib.mjs'; + +const BASE = (process.env.BRIDGE_BASE || '').replace(/\/?$/, '/'); +const PASSWORD = process.env.BRIDGE_PASSWORD || ''; +const ORIGIN = (process.env.AS_ORIGIN || '').replace(/\/$/, ''); +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ''; +const SHOTS = { + signIn: 'bridge/sign-in.png', + unpaired: 'bridge/status-unpaired.png', + paired: 'bridge/status-paired.png', + libraries: 'bridge/libraries.png', + accounts: 'bridge/accounts.png', +}; +if (!wanted(...Object.values(SHOTS))) process.exit(0); +if (!BASE || !PASSWORD || !ORIGIN || !ADMIN_PASSWORD) { + console.error('capture-bridge: BRIDGE_BASE, BRIDGE_PASSWORD, AS_ORIGIN and ADMIN_PASSWORD are required'); + process.exit(1); +} + +const server = apiClient(ORIGIN); +// The Bridge page's own API, as the page calls it (a bearer token from /api/login). +const bridgeApi = apiClient(BASE.replace(/\/$/, ''), '/api'); + +// Settles, moves the pointer away and shoots the page as tall as its content. +const tallShoot = async (page, file) => { + await sleep(800); + await page.mouse.move(0, 0); + await fitted(page, () => fitViewportToPage(page, 900, 1600, 400), () => shoot(page, file)); +}; + +const tab = async (page, name) => { + await page.getByRole('tab', {name, exact: true}).or(page.getByRole('link', {name, exact: true})).first().click({timeout: 8000}); + await sleep(800); +}; + +// What a reader's own container would show, not this machine's: the page's answers name +// the library folders as a container sees them (the seeded library at /library, as in the +// compose examples, the +// others under /), the server by the compose file's service name, and the speech +// recognition a Linux image picks (whisper.cpp on the CPU; on a Mac the Bridge picks +// mlx-whisper). Only the page's JSON is rewritten on the way in; the Bridge itself works +// with the real ones. +const SHOWN_SERVER = 'http://audiosilo:8080'; +const shown = (text) => + text + .replaceAll(path.join(CACHE, 'library'), '/library') + .replaceAll(`${CACHE}/`, '/') + .replaceAll(ORIGIN, SHOWN_SERVER) + .replaceAll('"mlx-whisper"', '"whisper-cpp"') + .replaceAll('"device":"metal"', '"device":"cpu"'); +const browser = await chromium.launch(); +const ctx = await browser.newContext(DESKTOP_CONTEXT); +await ctx.route( + (url) => url.origin === new URL(BASE).origin && /^\/api\/(status|libraries|engine)/.test(url.pathname), + async (route) => { + const res = await route.fetch(); + return route.fulfill({response: res, body: shown(await res.text())}); + }, +); +const page = await ctx.newPage(); +let admin = null; +// The Bridge's page token once it is paired (null until then). +let btoken = null; +console.log('== bridge page =='); +try { + await step('bridge: sign in', async () => { + await page.goto(BASE, {waitUntil: 'networkidle', timeout: 30000}); + await page.getByLabel('Password', {exact: true}).waitFor({timeout: 15000}); + await tallShoot(page, SHOTS.signIn); + await page.getByLabel('Password', {exact: true}).fill(PASSWORD); + await page.getByRole('button', {name: 'Sign in', exact: true}).click(); + await page.getByText('Pair with a server', {exact: true}).waitFor({timeout: 15000}); + }); + + const {token} = await server(null, 'POST', '/auth/login', {username: 'admin', password: ADMIN_PASSWORD}); + admin = token; + const pair = await server(admin, 'POST', '/admin/bridge/pair'); + + await step('bridge: status, not paired', async () => { + // The form as someone fills it in from the console's Connect a bridge dialog (the + // compose file's address in the shot, this server's for the real pairing). + await page.getByLabel('Server address').fill(SHOWN_SERVER); + await page.getByLabel('Pairing code').fill(pair.code); + await tallShoot(page, SHOTS.unpaired); + }); + + await step('bridge: pair', async () => { + await page.getByLabel('Server address').fill(ORIGIN); + await page.getByRole('button', {name: 'Pair', exact: true}).click(); + await page.getByRole('button', {name: 'Unpair...', exact: true}).waitFor({timeout: 30000}); + btoken = await page.evaluate(() => localStorage.getItem('audiosilo_bridge_token')); + }); + + if (btoken) { + // The libraries arrive with the first heartbeat after pairing. + let libs = []; + for (const until = Date.now() + 60000; Date.now() < until; await sleep(1000)) { + libs = (await bridgeApi(btoken, 'GET', '/libraries'))?.libraries ?? []; + if (libs.length) break; + } + if (!libs.length) console.log(' ! the Bridge listed no libraries after pairing'); + // Each library is found at the server's own path (this Bridge runs beside the + // server, as one with the same volumes would): give its check a moment. + await sleep(5000); + + await step('bridge: status, paired', async () => { + await page.reload({waitUntil: 'networkidle'}); + await page.getByRole('button', {name: 'Unpair...', exact: true}).waitFor({timeout: 15000}); + await tallShoot(page, SHOTS.paired); + }); + await step('bridge: libraries', async () => { + await tab(page, 'Libraries'); + await page.getByRole('button', {name: 'Check now'}).waitFor({timeout: 15000}); + await sleep(1500); + await tallShoot(page, SHOTS.libraries); + }); + await step('bridge: accounts', async () => { + await tab(page, 'Accounts'); + await page.getByText('GitHub', {exact: true}).first().waitFor({timeout: 15000}); + await sleep(1500); + await tallShoot(page, SHOTS.accounts); + }); + } +} finally { + if (btoken) await bridgeApi(btoken, 'POST', '/unpair').catch((e) => console.log(` ! Bridge left paired: ${e.message}`)); + if (admin) { + if (btoken) await server(admin, 'DELETE', '/admin/bridge').catch((e) => console.log(` ! server left paired: ${e.message}`)); + await server(admin, 'POST', '/auth/logout').catch(() => {}); + } + await browser.close(); +} diff --git a/screenshots/capture-setup.mjs b/screenshots/capture-setup.mjs index 9e298129..507cddfd 100644 --- a/screenshots/capture-setup.mjs +++ b/screenshots/capture-setup.mjs @@ -25,13 +25,22 @@ import {mkdir, rm} from 'node:fs/promises'; import path from 'node:path'; import {chromium} from 'playwright'; import { - CACHE, DESKTOP_CONTEXT, around, fitViewportToPage, shoot, silence, sleep, step, submitSignIn, wanted, + CACHE, DESKTOP_CONTEXT, around, fitViewportToPage, shoot, showDockerPaths, silence, sleep, step, submitSignIn, wanted, } from './lib.mjs'; import {SHOTS as MANIFEST} from './manifest.mjs'; const SETUP_URL = process.env.SETUP_URL || ''; const BOOKS = path.resolve(process.env.SETUP_BOOKS || path.join(CACHE, 'setup', 'Audiobooks')); const HOST = process.env.SETUP_HOST || 'audiosilo.local'; +// The flow shows this machine's folders as a Docker install has them (lib.mjs +// showDockerPaths): the books at /library, the data folder at /data. +const SHOWN_BOOKS = '/library'; +const SHOWN = [ + [BOOKS, SHOWN_BOOKS], + [path.join(CACHE, 'setup-data'), '/data'], + ...(process.env.WEB_DIR ? [[path.resolve(process.env.WEB_DIR), '/app/web']] : []), + [CACHE, '/srv'], +]; const PASSWORD = 'hearthside-setup'; if (!wanted(...MANIFEST.filter((s) => s.capture === 'setup').map((s) => s.file))) { console.log('capture-setup: no setup shots wanted, skipped.'); @@ -75,6 +84,7 @@ await step('build the setup books folder', async () => { // ── Capture ──────────────────────────────────────────────────────────────── const browser = await chromium.launch({args: [`--host-resolver-rules=MAP ${HOST} 127.0.0.1`]}); const ctx = await browser.newContext(DESKTOP_CONTEXT); +await showDockerPaths(ctx, SHOWN, [['"os":"darwin"', '"os":"linux"'], ['"arch":"arm64"', '"arch":"amd64"']], {[HOST]: '127.0.0.1'}); const page = await ctx.newPage(); // Makes the viewport as tall as the page (between min and max) for one shot. @@ -113,7 +123,7 @@ await step('the console flow', async () => { await heading('Where are your books?').waitFor({timeout: 20000}); // 1. Books folder: typed, checked, named. - await page.getByLabel('Books folder', {exact: true}).fill(BOOKS); + await page.getByLabel('Books folder', {exact: true}).fill(SHOWN_BOOKS); await button('Check folder').click(); await page.getByText(/^Found \d+ audio files$/).waitFor({timeout: 30000}); await page.getByLabel('Library name', {exact: true}).fill('Audiobooks'); diff --git a/screenshots/capture-web.mjs b/screenshots/capture-web.mjs index ae2d8d6e..1d1585a9 100644 --- a/screenshots/capture-web.mjs +++ b/screenshots/capture-web.mjs @@ -34,11 +34,15 @@ import { FIXTURES, HOUR, MIN, + PHONE, PODCAST_DIRECTORY, addHousehold, apiClient, around, firstVisible, + fitViewportTo, + fitted, + imagesLoaded, isoAgo, pathQuery, shoot, @@ -64,6 +68,18 @@ const SERIES = [ {author: 'Lewis Carroll', title: "Alice's Adventures", series: 'Alice', index: '1'}, {author: 'Lewis Carroll', title: 'Looking-Glass', series: 'Alice', index: '2'}, ]; +// Genres added to the seeded books the same way (a label edit adds to what the files +// say), so Library > Books has a Genre filter worth opening (web-player/library-genre.png). +const GENRES = [ + {title: 'Adventures of Sherlock Holmes', genres: ['Mystery', 'Classics']}, + {title: 'Hound of the Baskervilles', genres: ['Mystery', 'Classics']}, + {title: "Alice's Adventures", genres: ['Fantasy', 'Classics']}, + {title: 'Looking-Glass', genres: ['Fantasy', 'Classics']}, + {title: 'Call of the Wild', genres: ['Adventure', 'Classics']}, + {title: 'Scarlet Plague', genres: ['Science fiction']}, + {title: 'Christmas Carol', genres: ['Classics']}, + {title: 'Art of War', genres: ['Non-fiction']}, +]; // The demo user's Up next queue and one collection (title substrings of seeded books). const QUEUE = ['Christmas Carol', 'Adventures of Sherlock Holmes', 'Art of War']; @@ -280,24 +296,44 @@ await mkdir(CACHE, {recursive: true}); // ── Pass 0: series tags ───────────────────────────────────────────────────── console.log('== provision series =='); -await step('series tags', async () => { - if (!ADMIN_PASSWORD) throw new Error('ADMIN_PASSWORD not set: the series shots will show no series'); +// One admin session for both; signed out again, so it never shows in the admin +// Devices shot. +const asAdmin = async (fn) => { + if (!ADMIN_PASSWORD) throw new Error('ADMIN_PASSWORD not set: the series shots show no series, the Genre filter no genres'); const {token} = await api(null, 'POST', '/auth/login', {username: 'admin', password: ADMIN_PASSWORD}); try { + await fn(token); + } finally { + await api(token, 'POST', '/auth/logout').catch(() => {}); + } +}; +const seededBook = (books, title) => { + const b = books.find((x) => `${x.title} ${x.path}`.includes(title)); + if (!b) throw new Error(`no seeded book matching "${title}"`); + return b; +}; +await step('series tags', () => + asAdmin(async (token) => { for (const s of SERIES) { const list = await api(token, 'GET', `/admin/books?author=${encodeURIComponent(s.author)}&limit=50`); - const b = (list?.books ?? []).find((x) => `${x.title} ${x.path}`.includes(s.title)); - if (!b) throw new Error(`no seeded book matching "${s.title}"`); - await api(token, 'PATCH', `/admin/libraries/${b.library_id}/book?path=${encodeURIComponent(b.path)}`, { + const b = seededBook(list?.books ?? [], s.title); + await api(token, 'PATCH', `/admin/libraries/${b.library_id}/book${pathQuery(b.path)}`, { set: {series: s.series, series_index: s.index}, }); } - } finally { - // Sign this session out again, so it never shows in the admin Devices shot. - await api(token, 'POST', '/auth/logout').catch(() => {}); - } - console.log(` ✓ series: ${SERIES.map((s) => `${s.series} ${s.index}`).join(', ')}`); -}); + console.log(` ✓ series: ${SERIES.map((s) => `${s.series} ${s.index}`).join(', ')}`); + }), +); +await step('genres', () => + asAdmin(async (token) => { + const books = (await api(token, 'GET', '/admin/books?limit=200'))?.books ?? []; + for (const g of GENRES) { + const b = seededBook(books, g.title); + await api(token, 'PATCH', `/admin/libraries/${b.library_id}/book${pathQuery(b.path)}`, {add: {genres: g.genres}}); + } + console.log(` ✓ genres on ${GENRES.length} books`); + }), +); // ── Pass 1: warm a demo session ───────────────────────────────────────────── console.log('== warm demo session =='); @@ -652,6 +688,23 @@ async function captureWide(name, viewport, shots) { }); } + if (wanted(shots.bookRecapsGenerated)) { + // The same book's Recaps tab: the recaps.json run.sh seeds, with the Generated on + // this server mark, the recaps past the listener's place hidden and Show anyway. + await step('generated recaps', async () => { + await librarySection(page, 'Books'); + await openNamed(page, 'The Call of the Wild'); + await tid(page, 'book-tab-recaps').click({timeout: 15000}); + // The mark heads the tab (generated-mark) when all of it was generated here. + await firstVisible(page.getByTestId('generated-mark').or(page.getByTestId('recaps-generated-mark'))).waitFor({timeout: 15000}); + await scrollToTop(tid(page, 'book-tab-recaps')); + await page.mouse.wheel(0, -80); + await sleep(SETTLE_MS); + await page.mouse.move(0, 0); + await shoot(page, shots.bookRecapsGenerated); + }); + } + if (wanted(shots.journal, shots.journalBookmarks)) { // The Journal from the profile menu: the Diary (today's drift-off strip), then // the Bookmarks tab with its label filter. @@ -779,6 +832,26 @@ async function captureWide(name, viewport, shots) { }); } + if (wanted(shots.libraryGenre)) { + // The Genre filter's choices over Library > Books (the genres pass 0 adds), closed + // again with Escape. + await step('library genre', async () => { + await tid(page, 'top-bar-(library)').click({timeout: 8000}); + await sleep(1500); + await firstVisible(page.getByRole('button', {name: /^Genre\b/})).click({timeout: 8000}); + const dialog = firstVisible(page.getByRole('dialog')); + try { + await dialog.getByRole('radio', {name: /^Classics, /}).waitFor({timeout: 15000}); + await sleep(SETTLE_MS); + await page.mouse.move(0, 0); + await shoot(page, shots.libraryGenre); + } finally { + await page.keyboard.press('Escape'); + await dialog.waitFor({state: 'hidden', timeout: 8000}).catch(() => {}); + } + }); + } + if (wanted(shots.upNext)) { // The drawer beside Library > Books, opened from the top bar (and closed again). await step('up next', async () => { @@ -946,6 +1019,7 @@ await captureWide('desktop', {width: 1440, height: 900}, { bookDetails: 'web-player/book-details.png', bookFinished: 'web-player/book-finished.png', bookAboutGenerated: 'web-player/book-about-generated.png', + bookRecapsGenerated: 'web-player/book-recaps-generated.png', journal: 'web-player/journal.png', journalBookmarks: 'web-player/journal-bookmarks.png', player: 'web-player/player.png', @@ -954,6 +1028,7 @@ await captureWide('desktop', {width: 1440, height: 900}, { palette: 'web-player/palette.png', profile: 'web-player/profile-menu.png', library: 'web-player/library.png', + libraryGenre: 'web-player/library-genre.png', upNext: 'web-player/up-next.png', librarySeries: 'web-player/library-series.png', series: 'web-player/series.png', @@ -970,7 +1045,7 @@ await captureWide('tablet', {width: 834, height: 1112}, { home: 'web-player/tablet-home.png', }); -await capturePhone('phone', {width: 430, height: 932}, { +await capturePhone('phone', PHONE, { home: 'web-player/phone-home.png', book: 'web-player/phone-book-detail.png', player: 'web-player/phone-player.png', @@ -1007,7 +1082,6 @@ const PODCAST_CLIENT = {'x-audiosilo-client': 'AudioSilo/2.2.0 (web)'}; // show whose newest episode (not on the server) the fetching shot starts. const PODCAST_SHOW = 'The Lantern Archive'; const PODCAST_FETCH_SHOW = 'Field Notes at Dawn'; -const PHONE = {width: 430, height: 932}; // The fixture's shows, each with its feed.json as run.sh wrote it, and its directory. function podcastFixture() { @@ -1149,7 +1223,12 @@ async function capturePodcasts(pod) { await tid(page, 'podcast-show-hero').waitFor({timeout: 15000}); await tid(page, 'podcast-following-badge').waitFor({timeout: 15000}); await firstVisible(page.getByTestId('episode-row')).waitFor({timeout: 15000}); - await settleShoot(page, PODCAST_SHOTS.show); + // Down to the last episode's reason line. + await fitted( + page, + () => fitViewportTo(page, page.getByTestId('episode-row')), + () => settleShoot(page, PODCAST_SHOTS.show), + ); }); } @@ -1309,12 +1388,19 @@ async function findShowsStand(pod) { }); const slow = pod.shows.find((s) => s.folder === seededResult(pod).folder); const lantern = pod.shows.find((s) => s.folder === PODCAST_SHOW); + // outline_index counts every outline in FIND_OPML from 1, categories too: entry 1 + // is the "Food" category, so the list rightly starts at Entry 2. const importRows = [ row(2, 'followed', {feed: feedLabel(slow.feed.feed_url), path: slow.folder}), row(3, 'already_following', {feed: feedLabel(lantern.feed.feed_url), path: lantern.folder}), ...importRequests.map((r, i) => row(4 + i, 'queued', {feed: r.feed, private: r.private, request_id: r.id})), row(9, 'duplicate', {feed: importRequests[0].feed, duplicate_of: 4}), row(10, 'invalid', {error: {code: 'invalid_feed_url', message: 'invalid feed URL'}}), + // The follow that failed earlier (request 88, below), passed over: it shows that reason. + row(11, 'error', { + feed: 'https://www.oldmill.example/...', request_id: 88, + error: {code: 'recently_failed', message: 'Following this feed failed within the last hour. Follow it on its own to try again.'}, + }), ...['feeds.northlight.example', 'audio.cellarclub.example', 'rss.weekendwalks.example'].map((host, i) => row(12 + i, 'limited', {feed: `https://${host}/...`}), ), @@ -1326,7 +1412,7 @@ async function findShowsStand(pod) { nextId: 92, requests: [ request(91, 'running', 'https://feeds.harbourlights.example/...', 4 * MIN), - request(88, 'failed', 'https://www.oldmill.example/...', 3 * HOUR, {error: {code: 'not_a_feed', message: 'not a podcast feed'}}), + request(88, 'failed', 'https://www.oldmill.example/...', 40 * MIN, {error: {code: 'not_a_feed', message: 'not a podcast feed'}}), ], importRequests, importRows, @@ -1357,7 +1443,8 @@ async function captureFindShows(ctx, pod, cid) { await p.getByRole('button', {name: `Follow ${newResult(pod).title}`}).click({timeout: 8000}); await tid(p, 'discovery-result-adding').waitFor({timeout: 15000}); } - await sleep(2000); // the artwork + await imagesLoaded(p, '/podcasts/discovery/artwork'); + await sleep(SETTLE_MS); }; const desk = await sameDevice(ctx); try { @@ -1394,9 +1481,16 @@ async function captureFindShows(ctx, pod, cid) { await (await chooser).setFiles({name: 'podcast-subscriptions.opml', mimeType: 'text/x-opml', buffer: Buffer.from(FIND_OPML)}); await tid(p, 'opml-summary').waitFor({timeout: 15000}); await p.getByText('Waiting for the shows being added', {exact: false}).waitFor({timeout: 15000}); - await sleep(SETTLE_MS); - await p.mouse.move(0, 0); - await shoot(p, FIND_SHOTS.import); + // Every row of the list, not the first 900 px. + await fitted( + p, + () => fitViewportTo(p, p.getByTestId('opml-import')), + async () => { + await sleep(SETTLE_MS); + await p.mouse.move(0, 0); + await shoot(p, FIND_SHOTS.import); + }, + ); }); } } finally { @@ -1913,7 +2007,7 @@ async function captureHouse(house) { if (wanted(HOUSE_SHOTS.phonePage)) { // The Me tab's Household segment on a phone (the same device's storage). await step('household: phone', async () => { - const phone = await sameDevice(ctx, {viewport: {width: 430, height: 932}, timezoneId}); + const phone = await sameDevice(ctx, {viewport: PHONE, timezoneId}); try { const p = await phone.newPage(); await openHousehold(p, 'you?section=household'); @@ -1994,7 +2088,7 @@ async function captureHouse(house) { if (wanted(HOUSE_SHOTS.phoneKids)) { await step('household: phone kids', async () => { // This device's storage, from the live browser (kidsStaged picks the child itself). - const phone = await sameDevice(ctx, {viewport: {width: 430, height: 932}, timezoneId}); + const phone = await sameDevice(ctx, {viewport: PHONE, timezoneId}); try { const p = await phone.newPage(); await kidsStaged(p, kid.name); diff --git a/screenshots/lib.mjs b/screenshots/lib.mjs index f1e285a2..79aa8936 100644 --- a/screenshots/lib.mjs +++ b/screenshots/lib.mjs @@ -49,6 +49,9 @@ export const DESKTOP_CONTEXT = { reducedMotion: 'reduce', }; +// The phone viewport (a large iPhone, in CSS pixels) every phone shot uses. +export const PHONE = {width: 430, height: 932}; + // 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 // DESKTOP_CONTEXT.viewport. @@ -58,6 +61,86 @@ export const fitViewportToPage = async (p, min, max, settle = 800) => { await sleep(settle); }; +// Makes the viewport (at its width) tall enough to show `locator`'s last visible match +// down to its bottom plus `pad`, between min and max CSS pixels: for a player screen, +// which scrolls inside the page, so fitViewportToPage can't measure it. Measured twice, +// since a windowed list renders more rows in a taller viewport. The caller restores +// the viewport. +export const fitViewportTo = async (p, locator, {min = 900, max = 1800, pad = 32, settle = 800} = {}) => { + for (let i = 0; i < 2; i++) { + const box = await locator.filter({visible: true}).last().boundingBox(); + if (!box) return; + const height = Math.round(Math.min(Math.max(box.y + box.height + pad, min), max)); + if (height === p.viewportSize().height) return; + await p.setViewportSize({width: p.viewportSize().width, height}); + await sleep(settle); + } +}; + +// Runs `fit` (one of the two above, bound to `p`), then `shot`, and restores +// DESKTOP_CONTEXT.viewport however the shot ends. +export const fitted = async (p, fit, shot) => { + await fit(); + try { + await shot(); + } finally { + await p.setViewportSize(DESKTOP_CONTEXT.viewport); + } +}; + +// Shows this machine's paths in a browser context (`ctx`) as a Docker install has them +// (the seeded library at /library, the data folder at /data, ...), so no shot names the +// capture machine's folders. `pairs` is [[real path, shown path], ...]. Every /api/ +// answer that is JSON or text has each real path replaced by its shown one; a request's +// query values and JSON body strings that start with a shown path go to the server as +// the real one, so a folder typed or picked in its shown spelling still works. Range +// requests (audio) pass untouched. `text` is further [from, to] swaps made on answers +// only (the host's OS and architecture, say). `resolve` maps a host name the browser +// resolves itself (--host-resolver-rules) to the address the fetch, made from node, +// must use. +export async function showDockerPaths(ctx, pairs, text = [], resolve = {}) { + const byReal = [...pairs].sort((a, b) => b[0].length - a[0].length); + const byShown = [...pairs].sort((a, b) => b[1].length - a[1].length); + const swaps = [...byReal, ...text]; + const toShown = (body) => swaps.reduce((t, [from, to]) => t.replaceAll(from, to), body); + const toReal = (v) => { + for (const [real, shown] of byShown) if (v === shown || v.startsWith(`${shown}/`)) return real + v.slice(shown.length); + return v; + }; + const deep = (v) => { + if (typeof v === 'string') return toReal(v); + if (Array.isArray(v)) return v.map(deep); + if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, deep(x)])); + return v; + }; + await ctx.route( + (url) => url.pathname.startsWith('/api/'), + async (route) => { + const req = route.request(); + if (req.headers().range) return route.continue(); + const url = new URL(req.url()); + for (const [k, v] of [...url.searchParams]) url.searchParams.set(k, toReal(v)); + const overrides = {}; + if (resolve[url.hostname]) { + overrides.headers = {...req.headers(), host: url.host}; + url.hostname = resolve[url.hostname]; + } + overrides.url = url.toString(); + const body = req.postData(); + if (body && (req.headers()['content-type'] ?? '').includes('json')) { + try { + overrides.postData = JSON.stringify(deep(JSON.parse(body))); + } catch { + /* not JSON after all: sent as it is */ + } + } + const res = await route.fetch(overrides); + if (!/json|text/.test(res.headers()['content-type'] ?? '')) return route.fulfill({response: res}); + return route.fulfill({response: res, body: toShown(await res.text())}); + }, + ); +} + // Fills the console's sign-in form (already open on `p`) as admin and submits it. export const submitSignIn = async (p, password) => { await p.getByLabel('Username', {exact: true}).fill('admin'); @@ -76,10 +159,11 @@ export const silence = (file, seconds, meta = {}, codec = ['-c:a', 'libmp3lame', ...codec, file, ]); -// A JSON client for a server's /api/v1: api(token, method, path, body?, headers?) -// returns the parsed body (null when empty or not JSON) and throws on a non-2xx. -export const apiClient = (origin) => async (token, method, p, body, headers = {}) => { - const res = await fetch(`${origin}/api/v1${p}`, { +// A JSON client for a server's /api/v1 (or another `prefix` on `origin`): +// api(token, method, path, body?, headers?) returns the parsed body (null when empty +// or not JSON) and throws on a non-2xx, with the code as err.status. +export const apiClient = (origin, prefix = '/api/v1') => async (token, method, p, body, headers = {}) => { + const res = await fetch(`${origin}${prefix}${p}`, { method, headers: { 'content-type': 'application/json', @@ -95,10 +179,48 @@ export const apiClient = (origin) => async (token, method, p, body, headers = {} } catch { /* non-JSON */ } - if (!res.ok) throw new Error(`${method} ${p} -> ${res.status} ${text.slice(0, 120)}`); + if (!res.ok) { + const err = new Error(`${method} ${p} -> ${res.status} ${text.slice(0, 120)}`); + err.status = res.status; + throw err; + } return json; }; +// Waits until no library scans or waits in the job queue (scans run one at a time), +// asking through `api` (an apiClient) as admin `token`. +export const waitForScans = async (api, token, timeoutMs = 90000) => { + const until = Date.now() + timeoutMs; + await sleep(500); + while (Date.now() < until) { + const libs = (await api(token, 'GET', '/admin/libraries'))?.libraries ?? []; + if (!libs.some((l) => l.scan?.running || l.scan?.queued)) return; + await sleep(500); + } + throw new Error('scans still running after the timeout'); +}; + +// Waits until every shown on `p` whose src contains `srcPart` has loaded (and +// at least one is shown), rather than sleeping a fixed time for artwork. Gives up +// after `timeoutMs` with a note: the shot is still taken. +export const imagesLoaded = (p, srcPart, timeoutMs = 15000) => + p + .waitForFunction( + (part) => { + const shown = [...document.images].filter((i) => i.src.includes(part) && i.getClientRects().length > 0); + return shown.length > 0 && shown.every((i) => i.complete && i.naturalWidth > 0); + }, + srcPart, + {timeout: timeoutMs}, + ) + .then( + () => true, + () => { + console.log(` ! images with "${srcPart}" still loading after ${timeoutMs} ms`); + return false; + }, + ); + // Raw Playwright PNGs at deviceScaleFactor 2 are large (a full desktop shot is // ~0.5-1.3 MB); pngquant's lossy-palette pass shrinks them ~60% with no // perceptible loss on UI captures. It's a deterministic transform run on every diff --git a/screenshots/manifest.mjs b/screenshots/manifest.mjs index d202ed87..75c8c875 100644 --- a/screenshots/manifest.mjs +++ b/screenshots/manifest.mjs @@ -11,11 +11,13 @@ // 'admin' - capture-admin.mjs (admin console + public pages) // 'setup' - capture-setup.mjs (the --setup page and the console's first-run flow) // 'meta' - capture-meta.mjs (the meta.audiosilo.app site via a local metaserve) +// 'bridge' - capture-bridge.mjs (the Bridge's own page, a real Bridge paired with the demo server) export const SHOTS = [ // ── Web player (desktop 1440x900, dark) ──────────────────────────────── {file: 'web-player/home.png', capture: 'web', title: 'Web player - Home', hint: 'Home (Up next drawer hidden): greeting + sync pill, the Now card with its chapter scale, This week (if the server has user_stats), Continue listening and the shelves, docked player bar'}, {file: 'web-player/library.png', capture: 'web', title: 'Web player - Library', hint: 'Library > Books (the default section): Books / Authors / Series / Narrators / Collections / Folders in the sub-nav with sort + grid/list, the filter chips and the cover grid with progress bars'}, + {file: 'web-player/library-genre.png', capture: 'web', title: 'Web player - Genre filter', hint: 'Library > Books with the Genre filter chip opened: the dialog of the library\'s genres (the ones the run adds to the seeded books) with how many books each would show, and Any genre'}, {file: 'web-player/library-series.png', capture: 'web', title: 'Web player - Library series', hint: 'Library > Series (/library?mode=series): series cards with their mini shelves (the seeded Sherlock Holmes series shows its gaps)'}, {file: 'web-player/series.png', capture: 'web', title: 'Web player - Series page', hint: 'the Sherlock Holmes series page: hero with the progress track, the bookcase with the book in progress face-out and dashed gaps, the list below'}, {file: 'web-player/author.png', capture: 'web', title: 'Web player - Author page', hint: "Lewis Carroll's author page: stat tiles, the Alice series shelf, other books"}, @@ -27,6 +29,7 @@ export const SHOTS = [ {file: 'web-player/book-details.png', capture: 'web', title: 'Web player - Book details tab', hint: 'the Details tab of the playing book (downloaded first, so Plays from this device): the notice, the files table (codec, about N kbps, length), the path on the server'}, {file: 'web-player/book-finished.png', capture: 'web', title: 'Web player - Finished book', hint: 'the page of the provisioned finished book: the Finished badge, the four stars, Listen again'}, {file: 'web-player/book-about-generated.png', capture: 'web', title: 'Web player - Generated description', hint: "The Call of the Wild's About card (clipped) on its book page: the description run.sh seeds into its .audiosilo folder (a description.json as a Bridge writes it), with the Generated on this server mark under the heading and no credit line; the book has no description of its own and no community match"}, + {file: 'web-player/book-recaps-generated.png', capture: 'web', title: 'Web player - Generated recaps', hint: "The Call of the Wild's Recaps tab on its book page (early in the book): the recaps run.sh seeds into its .audiosilo folder (a recaps.json as a Bridge writes it) with the Generated on this server mark, the Whole-book summary row marked Spoiler, the hidden-to-avoid-spoilers line with Show anyway"}, {file: 'web-player/journal.png', capture: 'web', title: 'Web player - Journal', hint: "the Journal's Diary from the profile menu (the You hub's Journal section): Today with its 24 hour bar and sessions, the drift-off strip under the provisioned sleep-timer session, earlier days below"}, {file: 'web-player/journal-bookmarks.png', capture: 'web', title: 'Web player - Journal bookmarks', hint: "the Journal's Bookmarks tab: the export actions, search, the label filter chips and every bookmark across books, newest first"}, {file: 'web-player/player.png', capture: 'web', title: 'Web player - Now playing', hint: 'the full player on a desktop, opened from the docked bar with the book paused: cover, status line, seek bar and timeline, transport, actions, and the companion column'}, @@ -162,6 +165,13 @@ export const SHOTS = [ {file: 'setup/bridge.png', capture: 'setup', title: 'Setup - Add a Bridge', hint: 'step 8 with no Bridge paired: what a Bridge does, the compose snippet, Connect a bridge, Skip and finish (as tall as the page)'}, {file: 'setup/finish-card.png', capture: 'setup', title: 'Setup - Finish setting up card', hint: "the Overview's Finish setting up card (clipped) after the flow finished with the Audiobookshelf and Bridge steps skipped"}, + // ── The Bridge's page (desktop 1440x900, dark; as tall as the page) ───── + {file: 'bridge/sign-in.png', capture: 'bridge', title: 'Bridge - Sign in', hint: "the Bridge page's sign-in card on a fresh Bridge: Password, Sign in, where the first password was printed, and the reset-password line"}, + {file: 'bridge/status-unpaired.png', capture: 'bridge', title: 'Bridge - Status, not paired', hint: 'the Status tab before pairing: the Get set up checklist, Pair with a server filled in (the server address and a pairing code from the console), This Bridge, the engine'}, + {file: 'bridge/status-paired.png', capture: 'bridge', title: 'Bridge - Status, paired', hint: 'the Status tab once paired with the demo server: what is left of Get set up, the Server card (name, address, Paired, Unpair...), This Bridge with its job kinds and lanes'}, + {file: 'bridge/libraries.png', capture: 'bridge', title: 'Bridge - Libraries', hint: "the Libraries tab after the first heartbeat: the server's libraries, the seeded one mapped to its folder on this machine with its check state, Check now"}, + {file: 'bridge/accounts.png', capture: 'bridge', title: 'Bridge - Accounts', hint: 'the Accounts tab: the GitHub card (not signed in, Sign in with GitHub) and the Agent card'}, + // ── AudioSilo Meta site (meta.audiosilo.app; desktop 1440x900, dark) ──── {file: 'meta/home.png', capture: 'meta', title: 'Meta - Home', hint: 'search hero, live stats band, latest additions grid'}, {file: 'meta/search.png', capture: 'meta', title: 'Meta - Search', hint: 'search dropdown with grouped work/person/series results'}, diff --git a/screenshots/run.sh b/screenshots/run.sh index ad55dca2..069196ec 100755 --- a/screenshots/run.sh +++ b/screenshots/run.sh @@ -13,7 +13,8 @@ # 4. builds the AudioSilo Meta data artifact + site and starts a local # metaserve (port 8795) for the meta.audiosilo.app site captures, # 5. runs the Playwright captures (web player + admin console + public pages -# + the first-run setup flow + the meta site), +# + the first-run setup flow + the meta site), and starts a real Bridge +# (from the BRIDGE checkout, when there is one) for its own page's shots, # 6. backfills placeholders for anything not captured. # # Prereqs: Go 1.26+, Node 24, ffmpeg/ffprobe, sqlite3, openssl, `npm install` + @@ -47,8 +48,14 @@ # is built when missing, else used as it is, so rebuild it after a # frontend change; relative paths are taken from where run.sh # starts), +# BRIDGE= (the audiosilo-bridge checkout, default the sibling +# clone; a private repo, so without it the Bridge page shots keep +# their previous images), SKIP_BRIDGE=1 (skip them anyway), +# BRIDGE_VERSION (the version the Bridge page shows, default the +# checkout's git describe; set the release's, e.g. 0.3.0), # SHOTS_PORT / SHOTS_SETUP_PORT / SHOTS_META_PORT / -# SHOTS_MIRROR_PORT (default 8790 / 8791 / 8795 / 8792 - move them +# SHOTS_MIRROR_PORT / SHOTS_BRIDGE_PORT (default 8790 / 8791 / 8795 / +# 8792 / 8793 - move them # when a run in another checkout holds those ports; two runs in one # checkout share .cache/ and clash). set -euo pipefail @@ -63,6 +70,7 @@ SERVER_BIN="$(abspath "${SERVER_BIN:-$SERVER/bin/audiosilo}")" FRONTEND="$(abspath "${FRONTEND:-$WORKSPACE/audiosilo-frontend}")" META="$(abspath "${META:-$WORKSPACE/audiosilo-meta}")" META_COMMUNITY="$(abspath "${META_COMMUNITY:-$WORKSPACE/audiosilo-meta-community}")" +BRIDGE="$(abspath "${BRIDGE:-$WORKSPACE/audiosilo-bridge}")" CACHE="$HERE/.cache" LIBRARY="$CACHE/library" DATA="$CACHE/data" @@ -71,6 +79,8 @@ PORT="${SHOTS_PORT:-8790}" SETUP_PORT="${SHOTS_SETUP_PORT:-8791}" META_PORT="${SHOTS_META_PORT:-8795}" MIRROR_PORT="${SHOTS_MIRROR_PORT:-8792}" +BRIDGE_PORT="${SHOTS_BRIDGE_PORT:-8793}" +BRIDGE_DATA="$CACHE/bridge-data" MIRROR_DATA="$CACHE/mirror-data" mkdir -p "$CACHE" @@ -169,6 +179,7 @@ cleanup() { [ -n "${MAIN_PID:-}" ] && kill "$MAIN_PID" 2>/dev/null || true [ -n "${SETUP_PID:-}" ] && kill "$SETUP_PID" 2>/dev/null || true [ -n "${META_PID:-}" ] && kill "$META_PID" 2>/dev/null || true + [ -n "${BRIDGE_PID:-}" ] && kill "$BRIDGE_PID" 2>/dev/null || true # Stopped before its folder goes, so nothing writes there meanwhile. [ -n "${MIRROR_PID:-}" ] && { kill "$MIRROR_PID" 2>/dev/null; wait "$MIRROR_PID" 2>/dev/null; } || true rm -rf "$MIRROR_DATA" @@ -191,6 +202,15 @@ shot_wanted() { # return 1 } +# Whether SHOTS_ONLY keeps any shot the manifest gives to capture (setup, bridge). +capture_wanted() { # + (cd "$HERE" && CAPTURE="$1" node --input-type=module -e ' + import {SHOTS} from "./manifest.mjs"; + import {wanted} from "./lib.mjs"; + process.exit(wanted(...SHOTS.filter((s) => s.capture === process.env.CAPTURE).map((s) => s.file)) ? 0 : 1); + ') +} + wait_healthy() { # for i in $(seq 1 60); do curl -fsS "$1" >/dev/null 2>&1 && return 0 @@ -221,11 +241,7 @@ MAIN_PID=$! # console's first-run flow (with the web player, so its phone step offers it). # It starts only when a setup shot is wanted. WANT_SETUP=0 -if [ "${SKIP_ADMIN:-0}" != "1" ] && (cd "$HERE" && node --input-type=module -e ' - import {SHOTS} from "./manifest.mjs"; - import {wanted} from "./lib.mjs"; - process.exit(wanted(...SHOTS.filter((s) => s.capture === "setup").map((s) => s.file)) ? 0 : 1); -'); then +if [ "${SKIP_ADMIN:-0}" != "1" ] && capture_wanted setup; then WANT_SETUP=1 fi if [ "$WANT_SETUP" = 1 ]; then @@ -248,6 +264,10 @@ if [ -z "$ADMIN_PASSWORD" ]; then fi SETUP_URL="" if [ "$WANT_SETUP" = 1 ]; then + # Healthy before its log is read: the setup URL is printed as it starts, and + # capture-setup.mjs opens it at once. + echo "==> waiting for the --setup server" + wait_healthy "http://127.0.0.1:$SETUP_PORT/healthz" "$CACHE/setup.log" "--setup server" SETUP_URL="$(grep -o "http://[^ ]*/setup#token=[^ ]*" "$CACHE/setup.log" | head -1 || true)" fi @@ -329,18 +349,53 @@ if [ "${SKIP_ADMIN:-0}" != "1" ]; then echo "==> capturing admin console + public pages" AS_ORIGIN="http://127.0.0.1:$PORT" ADMIN_PASSWORD="$ADMIN_PASSWORD" \ MIRROR_ORIGIN="${MIRROR_PASSWORD:+http://127.0.0.1:$MIRROR_PORT}" \ - MIRROR_PASSWORD="${MIRROR_PASSWORD:-}" node capture-admin.mjs + MIRROR_PASSWORD="${MIRROR_PASSWORD:-}" WEB_DIR="$FRONTEND/dist" node capture-admin.mjs echo "==> capturing the first-run setup flow" if [ "$WANT_SETUP" != 1 ]; then echo " no setup shots wanted, skipped." elif [ -n "$SETUP_URL" ]; then - SETUP_URL="$SETUP_URL" node capture-setup.mjs + SETUP_URL="$SETUP_URL" WEB_DIR="$FRONTEND/dist" node capture-setup.mjs else echo " ! no setup URL in $CACHE/setup.log: the setup shots keep their previous images" fi fi +# The Bridge's own page: a real Bridge, built with its page embedded, on a fresh +# data dir, paired with the demo server by capture-bridge.mjs (see its header). +if [ "${SKIP_BRIDGE:-0}" != "1" ] && capture_wanted bridge; then + echo "==> capturing the Bridge's page" + if [ ! -x "$BRIDGE/scripts/build-web.sh" ]; then + echo " ! no audiosilo-bridge checkout at $BRIDGE: the Bridge shots keep their previous images" + else + # Its page synced into the checkout (built, never committed), the binary into + # .cache, stamped with BRIDGE_VERSION (default: git describe, no leading v). + BRIDGE_VERSION="${BRIDGE_VERSION:-$(git -C "$BRIDGE" describe --tags --always 2>/dev/null | sed 's/^v//')}" + (cd "$BRIDGE" && scripts/build-web.sh --sync-only \ + && go build -tags embedui -ldflags "-X main.version=${BRIDGE_VERSION:-dev}" \ + -o "$CACHE/audiosilo-bridge" ./cmd/audiosilo-bridge) > "$CACHE/bridge-build.log" 2>&1 \ + || { echo " ! the Bridge build failed (see $CACHE/bridge-build.log)"; exit 1; } + rm -rf "$BRIDGE_DATA" "$CACHE/bridge-home" && mkdir -p "$BRIDGE_DATA" "$CACHE/bridge-home" + # As in a fresh container: none of this machine's tools on PATH (no gh sign-in, + # no local speech recognition) and a HOME of its own. + env -i PATH=/usr/bin:/bin:/usr/sbin:/sbin HOME="$CACHE/bridge-home" \ + "$CACHE/audiosilo-bridge" serve --data "$BRIDGE_DATA" --listen "127.0.0.1:$BRIDGE_PORT" \ + > "$CACHE/bridge.log" 2>&1 & + BRIDGE_PID=$! + wait_healthy "http://127.0.0.1:$BRIDGE_PORT/api/health" "$CACHE/bridge.log" "Bridge" + BRIDGE_PASSWORD="$(grep "The password for the Bridge's page is:" "$CACHE/bridge.log" | awk -F': ' '{print $2}' | tr -d ' ' || true)" + if [ -z "$BRIDGE_PASSWORD" ]; then + echo " ! no one-time password in $CACHE/bridge.log: the Bridge shots keep their previous images" + else + BRIDGE_BASE="http://127.0.0.1:$BRIDGE_PORT/" BRIDGE_PASSWORD="$BRIDGE_PASSWORD" \ + AS_ORIGIN="http://127.0.0.1:$PORT" ADMIN_PASSWORD="$ADMIN_PASSWORD" \ + node capture-bridge.mjs + fi + kill "$BRIDGE_PID" 2>/dev/null || true + BRIDGE_PID="" + fi +fi + if [ "${SKIP_META:-0}" != "1" ]; then echo "==> capturing the meta site" META_BASE="http://127.0.0.1:$META_PORT" node capture-meta.mjs diff --git a/static/img/screenshots/admin/about.png b/static/img/screenshots/admin/about.png index 481b1088..ffdae6be 100644 Binary files a/static/img/screenshots/admin/about.png and b/static/img/screenshots/admin/about.png differ diff --git a/static/img/screenshots/admin/activity-live.png b/static/img/screenshots/admin/activity-live.png index 881ac90a..74c2da35 100644 Binary files a/static/img/screenshots/admin/activity-live.png and b/static/img/screenshots/admin/activity-live.png differ diff --git a/static/img/screenshots/admin/activity-sessions.png b/static/img/screenshots/admin/activity-sessions.png index f61b3095..66cf151d 100644 Binary files a/static/img/screenshots/admin/activity-sessions.png and b/static/img/screenshots/admin/activity-sessions.png differ diff --git a/static/img/screenshots/admin/activity-year.png b/static/img/screenshots/admin/activity-year.png index 29ec49ca..f17f97a7 100644 Binary files a/static/img/screenshots/admin/activity-year.png and b/static/img/screenshots/admin/activity-year.png differ diff --git a/static/img/screenshots/admin/activity.png b/static/img/screenshots/admin/activity.png index 4fae0243..193003e6 100644 Binary files a/static/img/screenshots/admin/activity.png and b/static/img/screenshots/admin/activity.png differ diff --git a/static/img/screenshots/admin/audit.png b/static/img/screenshots/admin/audit.png index 6de1d31d..02946785 100644 Binary files a/static/img/screenshots/admin/audit.png and b/static/img/screenshots/admin/audit.png differ diff --git a/static/img/screenshots/admin/backup-restore.png b/static/img/screenshots/admin/backup-restore.png index 90169c8f..ecb20c97 100644 Binary files a/static/img/screenshots/admin/backup-restore.png and b/static/img/screenshots/admin/backup-restore.png differ diff --git a/static/img/screenshots/admin/bell.png b/static/img/screenshots/admin/bell.png index aaa1c89f..a0d31305 100644 Binary files a/static/img/screenshots/admin/bell.png and b/static/img/screenshots/admin/bell.png differ diff --git a/static/img/screenshots/admin/book-match.png b/static/img/screenshots/admin/book-match.png index 04e5d6ca..4d258948 100644 Binary files a/static/img/screenshots/admin/book-match.png and b/static/img/screenshots/admin/book-match.png differ diff --git a/static/img/screenshots/admin/book-story-recaps.png b/static/img/screenshots/admin/book-story-recaps.png index df2b5172..7854cc83 100644 Binary files a/static/img/screenshots/admin/book-story-recaps.png and b/static/img/screenshots/admin/book-story-recaps.png differ diff --git a/static/img/screenshots/admin/book-story.png b/static/img/screenshots/admin/book-story.png index fed4e04a..11cdd911 100644 Binary files a/static/img/screenshots/admin/book-story.png and b/static/img/screenshots/admin/book-story.png differ diff --git a/static/img/screenshots/admin/book.png b/static/img/screenshots/admin/book.png index d31e120a..5ad31828 100644 Binary files a/static/img/screenshots/admin/book.png and b/static/img/screenshots/admin/book.png differ diff --git a/static/img/screenshots/admin/books-table.png b/static/img/screenshots/admin/books-table.png index b007cb65..0c087940 100644 Binary files a/static/img/screenshots/admin/books-table.png and b/static/img/screenshots/admin/books-table.png differ diff --git a/static/img/screenshots/admin/books.png b/static/img/screenshots/admin/books.png index 45892a3e..e44fd457 100644 Binary files a/static/img/screenshots/admin/books.png and b/static/img/screenshots/admin/books.png differ diff --git a/static/img/screenshots/admin/bridge-pair.png b/static/img/screenshots/admin/bridge-pair.png index 76a965bb..ad15e0a9 100644 Binary files a/static/img/screenshots/admin/bridge-pair.png and b/static/img/screenshots/admin/bridge-pair.png differ diff --git a/static/img/screenshots/admin/detect-chapters-dialog.png b/static/img/screenshots/admin/detect-chapters-dialog.png index 5ff65d01..208162ed 100644 Binary files a/static/img/screenshots/admin/detect-chapters-dialog.png and b/static/img/screenshots/admin/detect-chapters-dialog.png differ diff --git a/static/img/screenshots/admin/detection.png b/static/img/screenshots/admin/detection.png index 9080968d..8a72190e 100644 Binary files a/static/img/screenshots/admin/detection.png and b/static/img/screenshots/admin/detection.png differ diff --git a/static/img/screenshots/admin/devices.png b/static/img/screenshots/admin/devices.png index 1d8004ac..c6567198 100644 Binary files a/static/img/screenshots/admin/devices.png and b/static/img/screenshots/admin/devices.png differ diff --git a/static/img/screenshots/admin/events.png b/static/img/screenshots/admin/events.png index b2d2c33c..df4f5b60 100644 Binary files a/static/img/screenshots/admin/events.png and b/static/img/screenshots/admin/events.png differ diff --git a/static/img/screenshots/admin/folders.png b/static/img/screenshots/admin/folders.png index 0041acde..5d16940c 100644 Binary files a/static/img/screenshots/admin/folders.png and b/static/img/screenshots/admin/folders.png differ diff --git a/static/img/screenshots/admin/health-bulk-match.png b/static/img/screenshots/admin/health-bulk-match.png index a4719d3b..92906e4c 100644 Binary files a/static/img/screenshots/admin/health-bulk-match.png and b/static/img/screenshots/admin/health-bulk-match.png differ diff --git a/static/img/screenshots/admin/health-detect-chapters.png b/static/img/screenshots/admin/health-detect-chapters.png index 35c45571..896a90ed 100644 Binary files a/static/img/screenshots/admin/health-detect-chapters.png and b/static/img/screenshots/admin/health-detect-chapters.png differ diff --git a/static/img/screenshots/admin/health-duplicates.png b/static/img/screenshots/admin/health-duplicates.png index 58c799c8..ed7c6f30 100644 Binary files a/static/img/screenshots/admin/health-duplicates.png and b/static/img/screenshots/admin/health-duplicates.png differ diff --git a/static/img/screenshots/admin/health-issues.png b/static/img/screenshots/admin/health-issues.png index 466057ea..f48042ac 100644 Binary files a/static/img/screenshots/admin/health-issues.png and b/static/img/screenshots/admin/health-issues.png differ diff --git a/static/img/screenshots/admin/health-jobs.png b/static/img/screenshots/admin/health-jobs.png index 95028bd6..1b094883 100644 Binary files a/static/img/screenshots/admin/health-jobs.png and b/static/img/screenshots/admin/health-jobs.png differ diff --git a/static/img/screenshots/admin/invite.png b/static/img/screenshots/admin/invite.png index d0977e94..9f39a029 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/admin/invites.png b/static/img/screenshots/admin/invites.png index 194a184b..dda11ad6 100644 Binary files a/static/img/screenshots/admin/invites.png and b/static/img/screenshots/admin/invites.png differ diff --git a/static/img/screenshots/admin/libraries.png b/static/img/screenshots/admin/libraries.png index 4fec3fd1..cbd6fc01 100644 Binary files a/static/img/screenshots/admin/libraries.png and b/static/img/screenshots/admin/libraries.png differ diff --git a/static/img/screenshots/admin/library-add-podcasts.png b/static/img/screenshots/admin/library-add-podcasts.png index fce4acd8..1fabe081 100644 Binary files a/static/img/screenshots/admin/library-add-podcasts.png and b/static/img/screenshots/admin/library-add-podcasts.png differ diff --git a/static/img/screenshots/admin/library-add.png b/static/img/screenshots/admin/library-add.png index a249d838..e24cdd58 100644 Binary files a/static/img/screenshots/admin/library-add.png and b/static/img/screenshots/admin/library-add.png differ diff --git a/static/img/screenshots/admin/library-edit.png b/static/img/screenshots/admin/library-edit.png index fd730427..80d4370c 100644 Binary files a/static/img/screenshots/admin/library-edit.png and b/static/img/screenshots/admin/library-edit.png differ diff --git a/static/img/screenshots/admin/login.png b/static/img/screenshots/admin/login.png index 2c885b70..1c7df397 100644 Binary files a/static/img/screenshots/admin/login.png and b/static/img/screenshots/admin/login.png differ diff --git a/static/img/screenshots/admin/logs.png b/static/img/screenshots/admin/logs.png index 13de77d1..d7e26410 100644 Binary files a/static/img/screenshots/admin/logs.png and b/static/img/screenshots/admin/logs.png differ diff --git a/static/img/screenshots/admin/overview-phone.png b/static/img/screenshots/admin/overview-phone.png index 0273abd7..8e7afb4c 100644 Binary files a/static/img/screenshots/admin/overview-phone.png and b/static/img/screenshots/admin/overview-phone.png differ diff --git a/static/img/screenshots/admin/overview.png b/static/img/screenshots/admin/overview.png index a0fd85b5..bd0645fd 100644 Binary files a/static/img/screenshots/admin/overview.png and b/static/img/screenshots/admin/overview.png differ diff --git a/static/img/screenshots/admin/palette-search.png b/static/img/screenshots/admin/palette-search.png index f4b4fd24..71f92495 100644 Binary files a/static/img/screenshots/admin/palette-search.png and b/static/img/screenshots/admin/palette-search.png differ diff --git a/static/img/screenshots/admin/palette.png b/static/img/screenshots/admin/palette.png index 19377e9a..42989429 100644 Binary files a/static/img/screenshots/admin/palette.png and b/static/img/screenshots/admin/palette.png differ diff --git a/static/img/screenshots/admin/people.png b/static/img/screenshots/admin/people.png index b96945e2..751fdee3 100644 Binary files a/static/img/screenshots/admin/people.png and b/static/img/screenshots/admin/people.png differ diff --git a/static/img/screenshots/admin/person-listening.png b/static/img/screenshots/admin/person-listening.png index 49e093bb..c6b1fa1f 100644 Binary files a/static/img/screenshots/admin/person-listening.png and b/static/img/screenshots/admin/person-listening.png differ diff --git a/static/img/screenshots/admin/settings-backups.png b/static/img/screenshots/admin/settings-backups.png index efc3722a..6e6ea92b 100644 Binary files a/static/img/screenshots/admin/settings-backups.png and b/static/img/screenshots/admin/settings-backups.png differ diff --git a/static/img/screenshots/admin/settings-bridge-paired.png b/static/img/screenshots/admin/settings-bridge-paired.png index b32ea88d..c18bbe23 100644 Binary files a/static/img/screenshots/admin/settings-bridge-paired.png and b/static/img/screenshots/admin/settings-bridge-paired.png differ diff --git a/static/img/screenshots/admin/settings-bridge.png b/static/img/screenshots/admin/settings-bridge.png index f9e88207..634a3833 100644 Binary files a/static/img/screenshots/admin/settings-bridge.png and b/static/img/screenshots/admin/settings-bridge.png differ diff --git a/static/img/screenshots/admin/settings-import.png b/static/img/screenshots/admin/settings-import.png index a3d9f66c..2f77bc4b 100644 Binary files a/static/img/screenshots/admin/settings-import.png and b/static/img/screenshots/admin/settings-import.png differ diff --git a/static/img/screenshots/admin/settings-metadata.png b/static/img/screenshots/admin/settings-metadata.png index a82b7539..8fdcf13b 100644 Binary files a/static/img/screenshots/admin/settings-metadata.png and b/static/img/screenshots/admin/settings-metadata.png differ diff --git a/static/img/screenshots/admin/settings-modules.png b/static/img/screenshots/admin/settings-modules.png index bf33e0ad..39543966 100644 Binary files a/static/img/screenshots/admin/settings-modules.png and b/static/img/screenshots/admin/settings-modules.png differ diff --git a/static/img/screenshots/admin/settings-network.png b/static/img/screenshots/admin/settings-network.png index 88a62732..87b40afb 100644 Binary files a/static/img/screenshots/admin/settings-network.png and b/static/img/screenshots/admin/settings-network.png differ diff --git a/static/img/screenshots/admin/settings-notifications.png b/static/img/screenshots/admin/settings-notifications.png index 7ae7270e..e89e10fe 100644 Binary files a/static/img/screenshots/admin/settings-notifications.png and b/static/img/screenshots/admin/settings-notifications.png differ diff --git a/static/img/screenshots/admin/settings.png b/static/img/screenshots/admin/settings.png index c3526cf7..fb5e40db 100644 Binary files a/static/img/screenshots/admin/settings.png and b/static/img/screenshots/admin/settings.png differ diff --git a/static/img/screenshots/admin/shares.png b/static/img/screenshots/admin/shares.png index f822fd42..491de7bd 100644 Binary files a/static/img/screenshots/admin/shares.png and b/static/img/screenshots/admin/shares.png differ diff --git a/static/img/screenshots/admin/system.png b/static/img/screenshots/admin/system.png index 89ba890c..522fb99e 100644 Binary files a/static/img/screenshots/admin/system.png and b/static/img/screenshots/admin/system.png differ diff --git a/static/img/screenshots/admin/update-notice.png b/static/img/screenshots/admin/update-notice.png index da299485..f3f7dce6 100644 Binary files a/static/img/screenshots/admin/update-notice.png and b/static/img/screenshots/admin/update-notice.png differ diff --git a/static/img/screenshots/bridge/accounts.png b/static/img/screenshots/bridge/accounts.png new file mode 100644 index 00000000..5a6f8893 Binary files /dev/null and b/static/img/screenshots/bridge/accounts.png differ diff --git a/static/img/screenshots/bridge/libraries.png b/static/img/screenshots/bridge/libraries.png new file mode 100644 index 00000000..89821e3e Binary files /dev/null and b/static/img/screenshots/bridge/libraries.png differ diff --git a/static/img/screenshots/bridge/sign-in.png b/static/img/screenshots/bridge/sign-in.png new file mode 100644 index 00000000..4b7bcbe8 Binary files /dev/null and b/static/img/screenshots/bridge/sign-in.png differ diff --git a/static/img/screenshots/bridge/status-paired.png b/static/img/screenshots/bridge/status-paired.png new file mode 100644 index 00000000..d3cc41dd Binary files /dev/null and b/static/img/screenshots/bridge/status-paired.png differ diff --git a/static/img/screenshots/bridge/status-unpaired.png b/static/img/screenshots/bridge/status-unpaired.png new file mode 100644 index 00000000..4044d047 Binary files /dev/null and b/static/img/screenshots/bridge/status-unpaired.png differ diff --git a/static/img/screenshots/server/connect-page.png b/static/img/screenshots/server/connect-page.png index 790ac612..c6372595 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/setup/backups.png b/static/img/screenshots/setup/backups.png index 0b94af52..ba874ba6 100644 Binary files a/static/img/screenshots/setup/backups.png and b/static/img/screenshots/setup/backups.png differ diff --git a/static/img/screenshots/setup/bridge.png b/static/img/screenshots/setup/bridge.png index a6b1feaf..db61d146 100644 Binary files a/static/img/screenshots/setup/bridge.png and b/static/img/screenshots/setup/bridge.png differ diff --git a/static/img/screenshots/setup/finish-card.png b/static/img/screenshots/setup/finish-card.png index 7390e26f..f888c318 100644 Binary files a/static/img/screenshots/setup/finish-card.png and b/static/img/screenshots/setup/finish-card.png differ diff --git a/static/img/screenshots/setup/folder.png b/static/img/screenshots/setup/folder.png index 4de622a2..6cbd927a 100644 Binary files a/static/img/screenshots/setup/folder.png and b/static/img/screenshots/setup/folder.png differ diff --git a/static/img/screenshots/setup/metadata.png b/static/img/screenshots/setup/metadata.png index 0bff5b13..18cf3200 100644 Binary files a/static/img/screenshots/setup/metadata.png and b/static/img/screenshots/setup/metadata.png differ diff --git a/static/img/screenshots/setup/phone.png b/static/img/screenshots/setup/phone.png index 24e21ebe..70127129 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/setup/reach.png b/static/img/screenshots/setup/reach.png index 2cb69861..0ddceb7d 100644 Binary files a/static/img/screenshots/setup/reach.png and b/static/img/screenshots/setup/reach.png differ diff --git a/static/img/screenshots/web-player/account.png b/static/img/screenshots/web-player/account.png index 485e997f..ad485263 100644 Binary files a/static/img/screenshots/web-player/account.png and b/static/img/screenshots/web-player/account.png differ diff --git a/static/img/screenshots/web-player/author.png b/static/img/screenshots/web-player/author.png index 4268f3f0..c47f55d9 100644 Binary files a/static/img/screenshots/web-player/author.png and b/static/img/screenshots/web-player/author.png differ diff --git a/static/img/screenshots/web-player/book-about-generated.png b/static/img/screenshots/web-player/book-about-generated.png index c2154d21..ad411fd8 100644 Binary files a/static/img/screenshots/web-player/book-about-generated.png and b/static/img/screenshots/web-player/book-about-generated.png differ diff --git a/static/img/screenshots/web-player/book-bookmarks.png b/static/img/screenshots/web-player/book-bookmarks.png index b36e2b2e..7a851c5b 100644 Binary files a/static/img/screenshots/web-player/book-bookmarks.png and b/static/img/screenshots/web-player/book-bookmarks.png differ diff --git a/static/img/screenshots/web-player/book-detail.png b/static/img/screenshots/web-player/book-detail.png index 5721e57d..4a8d1412 100644 Binary files a/static/img/screenshots/web-player/book-detail.png and b/static/img/screenshots/web-player/book-detail.png differ diff --git a/static/img/screenshots/web-player/book-details.png b/static/img/screenshots/web-player/book-details.png index 38715fa5..2ff71b3a 100644 Binary files a/static/img/screenshots/web-player/book-details.png and b/static/img/screenshots/web-player/book-details.png differ diff --git a/static/img/screenshots/web-player/book-finished.png b/static/img/screenshots/web-player/book-finished.png index de481b69..e07e565c 100644 Binary files a/static/img/screenshots/web-player/book-finished.png and b/static/img/screenshots/web-player/book-finished.png differ diff --git a/static/img/screenshots/web-player/book-in-the-house.png b/static/img/screenshots/web-player/book-in-the-house.png index 87e137c3..0c4b08a1 100644 Binary files a/static/img/screenshots/web-player/book-in-the-house.png and b/static/img/screenshots/web-player/book-in-the-house.png differ diff --git a/static/img/screenshots/web-player/book-recaps-generated.png b/static/img/screenshots/web-player/book-recaps-generated.png new file mode 100644 index 00000000..3e666c51 Binary files /dev/null and b/static/img/screenshots/web-player/book-recaps-generated.png differ diff --git a/static/img/screenshots/web-player/bookmark-editor.png b/static/img/screenshots/web-player/bookmark-editor.png index a8d6e8c4..aa3b61a3 100644 Binary files a/static/img/screenshots/web-player/bookmark-editor.png and b/static/img/screenshots/web-player/bookmark-editor.png differ diff --git a/static/img/screenshots/web-player/collection.png b/static/img/screenshots/web-player/collection.png index 0d8a71a6..c205a9e0 100644 Binary files a/static/img/screenshots/web-player/collection.png and b/static/img/screenshots/web-player/collection.png differ diff --git a/static/img/screenshots/web-player/connect-ready.png b/static/img/screenshots/web-player/connect-ready.png index e293c105..bbbfe855 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 ebfdf062..623e4813 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/demo.png b/static/img/screenshots/web-player/demo.png index cad26cce..1a8c6492 100644 Binary files a/static/img/screenshots/web-player/demo.png and b/static/img/screenshots/web-player/demo.png differ diff --git a/static/img/screenshots/web-player/dock-undo.png b/static/img/screenshots/web-player/dock-undo.png index 9fab8a97..67fcc256 100644 Binary files a/static/img/screenshots/web-player/dock-undo.png and b/static/img/screenshots/web-player/dock-undo.png differ diff --git a/static/img/screenshots/web-player/downloads.png b/static/img/screenshots/web-player/downloads.png index fd4c13ef..7998c569 100644 Binary files a/static/img/screenshots/web-player/downloads.png and b/static/img/screenshots/web-player/downloads.png differ diff --git a/static/img/screenshots/web-player/episode-fetching.png b/static/img/screenshots/web-player/episode-fetching.png index 53414851..23c83296 100644 Binary files a/static/img/screenshots/web-player/episode-fetching.png and b/static/img/screenshots/web-player/episode-fetching.png differ diff --git a/static/img/screenshots/web-player/find-shows-address.png b/static/img/screenshots/web-player/find-shows-address.png index 112d9e1a..7a5fd4d3 100644 Binary files a/static/img/screenshots/web-player/find-shows-address.png and b/static/img/screenshots/web-player/find-shows-address.png differ diff --git a/static/img/screenshots/web-player/find-shows-import.png b/static/img/screenshots/web-player/find-shows-import.png index 488878fe..8b618928 100644 Binary files a/static/img/screenshots/web-player/find-shows-import.png and b/static/img/screenshots/web-player/find-shows-import.png differ diff --git a/static/img/screenshots/web-player/find-shows-search.png b/static/img/screenshots/web-player/find-shows-search.png index 9a76db17..483cff2a 100644 Binary files a/static/img/screenshots/web-player/find-shows-search.png and b/static/img/screenshots/web-player/find-shows-search.png differ diff --git a/static/img/screenshots/web-player/home-house.png b/static/img/screenshots/web-player/home-house.png index 8081a51b..d44e2c91 100644 Binary files a/static/img/screenshots/web-player/home-house.png and b/static/img/screenshots/web-player/home-house.png differ diff --git a/static/img/screenshots/web-player/home-new-episodes.png b/static/img/screenshots/web-player/home-new-episodes.png index ddc6b89e..024b4ed2 100644 Binary files a/static/img/screenshots/web-player/home-new-episodes.png and b/static/img/screenshots/web-player/home-new-episodes.png differ diff --git a/static/img/screenshots/web-player/home.png b/static/img/screenshots/web-player/home.png index 83bd949e..33c196c3 100644 Binary files a/static/img/screenshots/web-player/home.png and b/static/img/screenshots/web-player/home.png differ diff --git a/static/img/screenshots/web-player/household-add-grownup.png b/static/img/screenshots/web-player/household-add-grownup.png index d92882a9..46f7c197 100644 Binary files a/static/img/screenshots/web-player/household-add-grownup.png and b/static/img/screenshots/web-player/household-add-grownup.png differ diff --git a/static/img/screenshots/web-player/household-member.png b/static/img/screenshots/web-player/household-member.png index d2d0f6f7..477472a7 100644 Binary files a/static/img/screenshots/web-player/household-member.png and b/static/img/screenshots/web-player/household-member.png differ diff --git a/static/img/screenshots/web-player/household.png b/static/img/screenshots/web-player/household.png index d84619de..33dcc5dc 100644 Binary files a/static/img/screenshots/web-player/household.png and b/static/img/screenshots/web-player/household.png differ diff --git a/static/img/screenshots/web-player/journal-bookmarks.png b/static/img/screenshots/web-player/journal-bookmarks.png index a65d9059..85617885 100644 Binary files a/static/img/screenshots/web-player/journal-bookmarks.png and b/static/img/screenshots/web-player/journal-bookmarks.png differ diff --git a/static/img/screenshots/web-player/journal.png b/static/img/screenshots/web-player/journal.png index 248476c2..d2ec9670 100644 Binary files a/static/img/screenshots/web-player/journal.png and b/static/img/screenshots/web-player/journal.png differ diff --git a/static/img/screenshots/web-player/library-genre.png b/static/img/screenshots/web-player/library-genre.png new file mode 100644 index 00000000..fae6b840 Binary files /dev/null and b/static/img/screenshots/web-player/library-genre.png differ diff --git a/static/img/screenshots/web-player/library-series.png b/static/img/screenshots/web-player/library-series.png index ef819a8b..042406b3 100644 Binary files a/static/img/screenshots/web-player/library-series.png and b/static/img/screenshots/web-player/library-series.png differ diff --git a/static/img/screenshots/web-player/library-shows.png b/static/img/screenshots/web-player/library-shows.png index 882093b3..e665a27e 100644 Binary files a/static/img/screenshots/web-player/library-shows.png and b/static/img/screenshots/web-player/library-shows.png differ diff --git a/static/img/screenshots/web-player/library.png b/static/img/screenshots/web-player/library.png index 6737157e..0498c10c 100644 Binary files a/static/img/screenshots/web-player/library.png and b/static/img/screenshots/web-player/library.png differ diff --git a/static/img/screenshots/web-player/palette.png b/static/img/screenshots/web-player/palette.png index 4118e367..8c409f19 100644 Binary files a/static/img/screenshots/web-player/palette.png and b/static/img/screenshots/web-player/palette.png differ diff --git a/static/img/screenshots/web-player/phone-book-detail.png b/static/img/screenshots/web-player/phone-book-detail.png index 97c27d20..23095977 100644 Binary files a/static/img/screenshots/web-player/phone-book-detail.png and b/static/img/screenshots/web-player/phone-book-detail.png differ diff --git a/static/img/screenshots/web-player/phone-find-shows.png b/static/img/screenshots/web-player/phone-find-shows.png index a8c6fb59..72717473 100644 Binary files a/static/img/screenshots/web-player/phone-find-shows.png and b/static/img/screenshots/web-player/phone-find-shows.png differ diff --git a/static/img/screenshots/web-player/phone-home.png b/static/img/screenshots/web-player/phone-home.png index b2dd43e3..fc5ac8b5 100644 Binary files a/static/img/screenshots/web-player/phone-home.png and b/static/img/screenshots/web-player/phone-home.png differ diff --git a/static/img/screenshots/web-player/phone-player.png b/static/img/screenshots/web-player/phone-player.png index 5bf6ea4e..5f3a5594 100644 Binary files a/static/img/screenshots/web-player/phone-player.png and b/static/img/screenshots/web-player/phone-player.png differ diff --git a/static/img/screenshots/web-player/phone-podcast-show.png b/static/img/screenshots/web-player/phone-podcast-show.png index 5cbc1e0e..1d542501 100644 Binary files a/static/img/screenshots/web-player/phone-podcast-show.png and b/static/img/screenshots/web-player/phone-podcast-show.png differ diff --git a/static/img/screenshots/web-player/phone-sleep-sheet.png b/static/img/screenshots/web-player/phone-sleep-sheet.png index 5afe81b9..58489680 100644 Binary files a/static/img/screenshots/web-player/phone-sleep-sheet.png and b/static/img/screenshots/web-player/phone-sleep-sheet.png differ diff --git a/static/img/screenshots/web-player/phone-up-next.png b/static/img/screenshots/web-player/phone-up-next.png index 1ea05dbb..05c9a28a 100644 Binary files a/static/img/screenshots/web-player/phone-up-next.png and b/static/img/screenshots/web-player/phone-up-next.png differ diff --git a/static/img/screenshots/web-player/phone-you-stats.png b/static/img/screenshots/web-player/phone-you-stats.png index a21501c0..441a4f2e 100644 Binary files a/static/img/screenshots/web-player/phone-you-stats.png and b/static/img/screenshots/web-player/phone-you-stats.png differ diff --git a/static/img/screenshots/web-player/player-cast.png b/static/img/screenshots/web-player/player-cast.png index 6c165e10..aaca028d 100644 Binary files a/static/img/screenshots/web-player/player-cast.png and b/static/img/screenshots/web-player/player-cast.png differ diff --git a/static/img/screenshots/web-player/player.png b/static/img/screenshots/web-player/player.png index 7a1f5582..619f2e1d 100644 Binary files a/static/img/screenshots/web-player/player.png and b/static/img/screenshots/web-player/player.png differ diff --git a/static/img/screenshots/web-player/podcast-show.png b/static/img/screenshots/web-player/podcast-show.png index f2638bb3..0bffe927 100644 Binary files a/static/img/screenshots/web-player/podcast-show.png and b/static/img/screenshots/web-player/podcast-show.png differ diff --git a/static/img/screenshots/web-player/profile-menu.png b/static/img/screenshots/web-player/profile-menu.png index f879b112..6ada4c99 100644 Binary files a/static/img/screenshots/web-player/profile-menu.png and b/static/img/screenshots/web-player/profile-menu.png differ diff --git a/static/img/screenshots/web-player/search.png b/static/img/screenshots/web-player/search.png index e739dca4..ac8ee3ca 100644 Binary files a/static/img/screenshots/web-player/search.png and b/static/img/screenshots/web-player/search.png differ diff --git a/static/img/screenshots/web-player/series.png b/static/img/screenshots/web-player/series.png index 4750d280..f4cb0cb8 100644 Binary files a/static/img/screenshots/web-player/series.png and b/static/img/screenshots/web-player/series.png differ diff --git a/static/img/screenshots/web-player/settings.png b/static/img/screenshots/web-player/settings.png index 4d0b714d..555264b7 100644 Binary files a/static/img/screenshots/web-player/settings.png and b/static/img/screenshots/web-player/settings.png differ diff --git a/static/img/screenshots/web-player/tablet-home.png b/static/img/screenshots/web-player/tablet-home.png index c31091c0..d48292b2 100644 Binary files a/static/img/screenshots/web-player/tablet-home.png and b/static/img/screenshots/web-player/tablet-home.png differ diff --git a/static/img/screenshots/web-player/up-next.png b/static/img/screenshots/web-player/up-next.png index e875d4ed..5e96ece4 100644 Binary files a/static/img/screenshots/web-player/up-next.png and b/static/img/screenshots/web-player/up-next.png differ diff --git a/static/img/screenshots/web-player/year.png b/static/img/screenshots/web-player/year.png index c59c47cc..0b395357 100644 Binary files a/static/img/screenshots/web-player/year.png and b/static/img/screenshots/web-player/year.png differ diff --git a/static/img/screenshots/web-player/you-stats.png b/static/img/screenshots/web-player/you-stats.png index 1221e05f..765dfbdf 100644 Binary files a/static/img/screenshots/web-player/you-stats.png and b/static/img/screenshots/web-player/you-stats.png differ