diff --git a/CLAUDE.md b/CLAUDE.md index 4bb69a4e..d4b7f114 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,8 @@ docs-users/ User Guide pages (getting-started, admin, listening, comm docs-developers/ Developer pages (architecture, server{,/api}, frontend, bridge, meta, contributing) sidebars-*.ts canonical page lists (hand-ordered) screenshots/ capture pipeline: manifest.mjs (source of truth), run.sh, - capture-web.mjs, capture-admin.mjs, placeholders.mjs, .cache/ (gitignored) + capture-web.mjs, capture-admin.mjs, capture-setup.mjs, capture-meta.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/invariants.md b/docs-developers/architecture/invariants.md index 66149bc9..87c0b44d 100644 --- a/docs-developers/architecture/invariants.md +++ b/docs-developers/architecture/invariants.md @@ -160,13 +160,13 @@ audiobooks" into "remote file read on your NAS". - **No default creds**: first run either prints admin credentials + an auth code **once** (the banner is the only place plaintext secrets ever appear) or, with - `--setup`, enables a one-time-token setup wizard at `/setup` + `--setup`, enables a one-time-token setup page at `/setup` (`internal/api/handlers_setup.go`) that self-closes the moment an admin exists. - **Hashed secrets**: tokens and auth codes are stored only as SHA-256 hashes; passwords use argon2id (`internal/auth/hash.go`). `auth.ResolveRequest` resolves opaque bearer tokens against the hashes. - **Fragment-carried codes**: invite links are `/connect#code=…` and the - setup wizard is `/setup#token=…` - fragments never reach the server or its + setup page is `/setup#token=…` - fragments never reach the server or its access logs. The setup POST verifies its token in constant time. - **Path safety**: `library.SafeJoin` rejects traversal outside the library root; every filesystem access derived from user input goes through it. Share scoping diff --git a/docs-developers/contributing/documentation.md b/docs-developers/contributing/documentation.md index cc9332c8..f20faaee 100644 --- a/docs-developers/contributing/documentation.md +++ b/docs-developers/contributing/documentation.md @@ -87,6 +87,7 @@ mappings: | Listening in the house (`src/components/house/`, the household hooks; server `catalog/household.go`) | [frontend/listening-in-the-house.md](../frontend/listening-in-the-house.md) + the [reference](../server/api/reference.md#listening-in-the-house) + [auth-and-security.md](../server/auth-and-security.md#listening-in-the-house-what-other-people-see) + `/users/listening/in-the-house`, `/users/admin/activity` (what the server records) | | Chromecast (`src/cast/`, `plugins/withCast.js`; server `auth/cast.go`, `handlers_cast.go`, the web CSP) | [frontend/chromecast.md](../frontend/chromecast.md) + the [reference](../server/api/reference.md#casting) + [auth-and-security.md](../server/auth-and-security.md#cast-tokens) + [server/web-ui.md](../server/web-ui.md#per-document-csp-htmlcsp) for the web CSP (`castSenderScripts`, the policy `TestHTMLCSPCastSender` pins) + `/users/listening/chromecast`, `/users/listening/playback` (other speakers), `/users/getting-started/remote-access` (the certificate note) | | Downloads / PWA, the offline companion (`offline-meta.ts`) | [frontend/offline.md](../frontend/offline.md) + `/users/listening/offline-downloads` | +| First-run setup: the `/setup` page (`handlers_setup.go`, `assets/setup.*`), the console's Set up your server flow and Overview card (`features/setup/`, `finish-setup-card.tsx`), `catalog/setup.go`, `library/preview.go`, `library/mounts.go`, `handlers_admin_setup.go` | `/users/getting-started/first-run` (a section and shot per step) + the quickstarts' setup step (`/users/getting-started/quickstart-docker`, `install-unraid`, `install-binary`) + `/users/admin/console-tour` (the card) + [web-ui.md](../server/web-ui.md#the-first-run-setup-flow) (the flow and the `/setup` page) + the [reference](../server/api/reference.md#admin-first-run-setup) (and `POST /setup`) + [api/index.md](../server/api/index.md#error-envelope-and-status-conventions) (the codes) + [data-model.md](../server/data-model.md#server-state) (`setup_state`, `0053`) + [configuration.md](../server/configuration.md) (`--setup`) + [backups-and-notifications.md](../server/backups-and-notifications.md#audit-log) (`settings.setup`) + the `server/setup-wizard*.png` and `setup/*.png` screenshots (`capture-setup.mjs`: `SHOTS_ONLY=setup/,server/setup-wizard screenshots/run.sh`) | | 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` | @@ -114,9 +115,9 @@ 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 setup wizard -with Playwright. Anything it can't reach gets a labelled placeholder so the -build never breaks. +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. +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/workspace.md b/docs-developers/contributing/workspace.md index e3e50937..20373bca 100644 --- a/docs-developers/contributing/workspace.md +++ b/docs-developers/contributing/workspace.md @@ -115,7 +115,7 @@ npm --prefix admin-ui run dev # admin console dev server (proxies ``` Useful flags: `--data` (config/db/certs dir), `--ffprobe`/`--ffmpeg` (`""` -disables), `--setup` (first-run browser wizard instead of the printed creds). +disables), `--setup` (choose the first admin's password on a browser page instead of the printed creds; the console's setup flow follows either way). **audiosilo-frontend** diff --git a/docs-developers/server/api/index.md b/docs-developers/server/api/index.md index 529c741b..0f586e7b 100644 --- a/docs-developers/server/api/index.md +++ b/docs-developers/server/api/index.md @@ -19,7 +19,7 @@ A few routes deliberately live *outside* the prefix: | Route | Why | |---|---| | `GET /healthz` (also served at `GET /api/v1/healthz`) | container/orchestrator healthchecks expect a root-level probe | -| `GET /setup`, `POST /setup` | the first-run setup wizard is a browser page, not an API surface (and is off unless the launcher enables it) | +| `GET /setup`, `POST /setup` | the first-run setup page is a browser page, not an API surface (and is off unless the launcher enables it); the console's first-run flow uses `/api/v1/admin/setup` | | `GET /.well-known/apple-app-site-association`, `GET /.well-known/assetlinks.json` | the well-known location is mandated by iOS/Android | | `/`, `/connect`, `/admin`, `/assets/…`, `/web/…` | the baked-in admin/connect UI and the web player - static pages over the API, not part of it (see [Web UI](../web-ui.md)) | @@ -183,8 +183,9 @@ Failures a person can fix also carry a machine-readable **`code`** next to | `admin_needs_password` | `400` | an admin without a password (creating one, promoting a password-less account, or clearing an admin's password) | | `password_too_short` | `400` | a password under the minimum length | | `cannot_delete_self` | `400` | an admin deleting their own account | -| `path_not_absolute` | `400` | `GET /admin/fs/dirs` with a relative `path` | -| `folder_unreadable` | `404` | `GET /admin/fs/dirs` on a missing or unreadable folder | +| `path_not_absolute` | `400` | `GET /admin/fs/dirs` or `POST /admin/setup/preview` with a relative path | +| `folder_unreadable` | `404` | `GET /admin/fs/dirs` or `POST /admin/setup/preview` on a missing or unreadable folder | +| `invalid_setup_step` | `400` | `PUT /admin/setup` naming a step id the flow doesn't have, or an outcome other than `done` / `skipped` | | `book_not_found` | `404` | an admin catalog call on a path that is not an indexed book (book page, edit, bulk edit, match, cover upload, community cover); an admin progress edit on a path with no progress and no indexed book; `POST /admin/bridge/jobs` and `GET /admin/bridge/estimate` on a path that is not an indexed book | | `invalid_override` | `400` | a metadata edit the server refuses; the body also carries a `field` key naming the offending field (`PATCH /admin/libraries/{id}/book`, `POST /admin/books/bulk`) | | `module_off` | `404` | any route a [managed module](../modules.md) mounts, while that module is off (Settings > Modules), for a caller the route's auth check admits (it runs first). The route exists either way, so turning the module on needs no restart. The `podcasts` module mounts `POST`/`DELETE /podcasts/follow` and `POST /podcasts/check`. Also `POST /admin/bridge/jobs` for a job the module owns (Phase 2b: `sidecars.contribute`, `meta.correct`, `sidecars.generate` with `contribute: "issue"`, the `contributions` module), checked before anything else about the job | @@ -193,7 +194,7 @@ Failures a person can fix also carry a machine-readable **`code`** next to | `unsupported_image` | `415` | a custom cover (uploaded or community) that is not a JPEG, PNG or WebP image | | `cover_unavailable` | `502` | `PUT /admin/libraries/{id}/cover/community` or `GET /libraries/{id}/meta/cover` when the server couldn't fetch the image (the latter also for a minute after a failed fetch, without asking again) | | `invalid_schedule` | `400` | a library `scan_schedule` that isn't `""`, `every:h` (1, 3, 6, 12, 24) or `daily:HH:MM` (`POST`/`PATCH /admin/libraries`) | -| `invalid_pattern` | `400` | a library `ignore_patterns` list the server refuses: more than 100 patterns, one over 200 bytes, one that matches nothing, or a malformed wildcard; the message names the line (`POST`/`PATCH /admin/libraries`) | +| `invalid_pattern` | `400` | a library `ignore_patterns` list the server refuses: more than 100 patterns, one over 200 bytes, one that matches nothing, or a malformed wildcard; the message names the line (`POST`/`PATCH /admin/libraries`, `POST /admin/setup/preview`) | | `invalid_metadata_source` | `400` | a library `metadata_source` other than `"tags"` or `"path"` (`POST`/`PATCH /admin/libraries`) | | `not_indexable` | `404` | `POST /admin/libraries/{id}/book/rescan` on a path with no book any more (gone, not a book, or skipped by the library's ignore rules); the Bridge's `POST /bridge/rescan` on a path with nothing indexable (with `mode: "attachments"`, no indexed book there) | | `current_device` | `409` | `DELETE /admin/devices/{id}` on the token making the request (sign out instead) | @@ -248,7 +249,7 @@ Podcast actions add `invalid_request` and `invalid_feed_url` (`400`), `no_library_access` (`403`), `show_not_found` (`404`), `not_podcast_library`, `bridge_unavailable` and `library_not_writable` (`409`), and `too_many_requests` / `checked_recently` (`429`). Library kinds add -`invalid_kind` / `kind_immutable` (`400`) and `root_overlaps` (`409`). An +`invalid_kind` / `kind_immutable` (`400`) and `root_overlaps` (`409`, also from `POST /admin/setup/preview`). An undownloaded episode streams as `404 not_downloaded`. Full shapes and async feed failures: [Podcasts](reference.md#podcasts). diff --git a/docs-developers/server/api/reference.md b/docs-developers/server/api/reference.md index 1bc18846..1542a155 100644 --- a/docs-developers/server/api/reference.md +++ b/docs-developers/server/api/reference.md @@ -4129,7 +4129,7 @@ Progress of the library's (possibly running) scan: A scan an admin request queues - `POST …/scan`, creating a library, an edit that changes the root or ignore rules, setting or clearing a folder override, -or the setup wizard - reads as `queued` (or `running`) before that request +or a `POST /setup` that sends library fields - reads as `queued` (or `running`) before that request returns, so a status poll made right after it sees the scan. ### `GET /api/v1/admin/fs/dirs` @@ -7361,6 +7361,174 @@ recorded in the [audit log](#admin-audit-log) as `settings.support`, with | `401` | no valid session or API key | | `403` | not an admin | +## Admin: first-run setup + +The admin console's "Set up your server" flow (FIRST-RUN-PLAN.md Phase F1; +`catalog/setup.go`, `library/preview.go`, `library/mounts.go`, +`api/handlers_admin_setup.go`). The flow writes every setting through the +ordinary admin routes ([settings](#admin-settings), [library +create and edit](#admin-libraries--shares)); these routes keep only its +progress and answer a read-only preview of a books folder. Only the console +asks: the player never sees them, and there is no capability flag. All three +are *Admin*: `401` without a valid session or API key, `403` for a non-admin. +What the console does with them is in +[The admin console](../web-ui.md#the-first-run-setup-flow). A server from +before the flow answers `404`. + +### `GET /api/v1/admin/setup` + +*Admin.* The flow's progress, and what its steps need to know about this +server and this request: + +```json +{ + "state": { + "finished": false, + "dismissed": false, + "steps": { "folder": "done", "naming": "skipped" } + }, + "request": { + "host": "nas:8080", + "scheme": "http", + "proxied": true, + "peer": "172.18.0.5", + "peer_trusted": false + }, + "config_libraries": ["Audiobooks"], + "mount_hints": ["/audiobooks", "/podcasts"] +} +``` + +- `state` is server-wide (the `server_state` key `setup_state`; see + [Data model](../data-model.md#server-state)). The step ids, in the console's + order, are `folder`, `naming`, `metadata`, `reach`, `abs`, `backups`, + `phone` and `bridge`, each `"done"` or `"skipped"`; an absent id is a step + not yet seen. A server with no row (a fresh one) answers `finished: false`, + `dismissed: false`, `steps: {}`; migration `0053` stored an upgraded server + as finished and dismissed. +- `request` is this request as the server saw it. The server never trusts + `X-Forwarded-Host` or `X-Forwarded-Proto`, so `host` (the `Host` header) and + `scheme` (`https` only for a TLS connection to the server itself) are the + direct connection's; the console compares them with the address the browser + uses. `proxied` is true when the request carried any forwarding header, and + only then is `peer` sent: the address that connected (the proxy), for + `network.trusted_proxies`. `peer_trusted` says whether that address is + already trusted. +- `config_libraries` are the names of the libraries `config.yaml` or the + environment declares (re-added at every start). +- `mount_hints` are folders mounted into the server that may hold books, read + from `/proc/self/mountinfo` on Linux: folders that exist, not system paths + (`/`, `/proc`, `/sys`, `/tmp`, `/dev`, `/run`, `/boot`, `/snap` and anything + 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). + +| Status | Meaning | +|---|---| +| `200` | the envelope | +| `401` / `403` | anonymous / non-admin | + +### `PUT /api/v1/admin/setup` + +*Admin.* Records the flow's progress. Every field is optional and only the +fields sent change; `steps` merges (an id it names takes the new outcome, the +others keep theirs), in one writer transaction. + +```json +{ "finished": true, "steps": { "bridge": "skipped" } } +``` + +Answers `200` with the whole `state` as `GET` returns it: + +```json +{ "finished": true, "dismissed": false, "steps": { "folder": "done", "bridge": "skipped" } } +``` + +Each request is recorded in the [audit log](#admin-audit-log) as +`settings.setup`, its details the fields it sent. Sending `dismissed: true` +hides the Overview's "Finish setting up" card for every admin. + +| Status | Meaning | +|---|---| +| `200` | saved; the state | +| `400` | `invalid request` (no body, not JSON, or a field other than `finished`, `dismissed` and `steps`), or `invalid_setup_step` (a step id the flow doesn't have, or an outcome other than `"done"` / `"skipped"`; nothing is saved) | +| `401` / `403` | anonymous / non-admin | + +### `POST /api/v1/admin/setup/preview` + +*Admin.* What a books library at a folder would hold, and a sample of its +books named both ways (from the files' tags and from the folder names), so +the flow can suggest the library's `metadata_source` before the library +exists. Read-only: it writes nothing to the index, returns names and counts +only (never a file's contents) and is not audited. + +```json +{ "root": "/audiobooks", "ignore_patterns": ["Extras/"] } +``` + +`ignore_patterns` is optional and checked as a library's skip rules are. + +```json +{ + "root": "/audiobooks", + "counts": { "audio_files": 812, "folder_books": 64, "file_books": 3, "capped": false }, + "same_root": null, + "samples": [ + { + "rel_path": "Arthur Conan Doyle/Sherlock Holmes/2 - The Sign of the Four", + "tags": { "title": "The Sign of Four (Unabridged)", "author": "Doyle, Arthur Conan", + "narrator": "David Clarke", "series": "Sherlock Holmes", "position": "2" }, + "path": { "title": "The Sign of the Four", "author": "Arthur Conan Doyle", + "narrator": "David Clarke", "series": "Sherlock Holmes", "position": "2" }, + "abs": false + } + ], + "blanks": { "tags": 4, "path": 1 }, + "suggested": "path" +} +``` + +- **The folder** is checked as the add-library folder picker checks it: an + absolute path the server can open and list. `root` comes back cleaned. +- **The root** is checked against the other libraries as library create checks + it, so a folder inside, or containing, a podcast library's folder is refused + with `409 root_overlaps`. Two books libraries at one folder are allowed, so + `same_root` names a library already at exactly this folder (a warning for + the console to show), else `null`. +- **The walk** discovers books as a scan does (folder books and single-file + books, the same detection, no folder overrides), bounded at 5,000 folders or + 10 seconds. `counts.capped` is true when the walk stopped early, so there are + more than it counted; it says nothing else. +- **The samples** are up to 8 books spread evenly over the books found in path + order, read four at a time as a scan reads names (the primary file's tags, + the folder's Audiobookshelf `metadata.json`; no fingerprint, chapters or + covers), all under the preview's overall limit of 15 seconds. A sample read + still running then is ended and left out, so the answer has **fewer + samples**; that never sets `capped`. +- `tags` and `path` are the names a library whose `metadata_source` is `"tags"` + or `"path"` would show for the book (the same resolution the index uses, so + the preview and the library never disagree): each side falls back to the + other for what it leaves empty. `position` is a series position as text + (`"2"`, `"1-3"` for a range). `abs` is true when the book's folder has a + `metadata.json`, which goes over both. +- `blanks` counts, over the samples, the names each source leaves empty **on + its own** (before any fallback): an empty title, author or series, and a + missing position beside a series. The tags count a generic title ("Track + 01") as none; the narrator is never counted, since both modes take it from + the tags. +- `suggested` is the source with fewer blanks, `"tags"` on a tie (also when + there are no samples). + +| Status | Meaning | +|---|---| +| `200` | the preview | +| `400` | `invalid request` (no body, not JSON, an unknown field), `root is required`, `path_not_absolute` (a relative `root`) or `invalid_pattern` (a skip rule the server refuses) | +| `401` / `403` | anonymous / non-admin | +| `404` | `folder_unreadable` - missing, not a folder, or unreadable by the server (the OS error is not echoed) | +| `409` | `root_overlaps` - overlaps a podcast library's folder | + ## The Bridge Capability `bridge`. The **Bridge** is an optional companion program that writes to @@ -8221,39 +8389,46 @@ Android App Links. Served when `app_links.android_package` **and** ] ``` -## First-run setup wizard +## First-run setup page Only active when the launcher enabled it (`--setup` / `pkg/launcher`); a normal -headless deployment never exposes this surface. The wizard self-closes the +headless deployment never exposes this surface. The page self-closes the moment an admin exists. The one-time setup token rides in the page URL **fragment** (`/setup#token=…`) so it never reaches server logs; the POST -verifies it in constant time. See [Web UI](../web-ui.md). +verifies it in constant time. The page asks only for the account; the admin +console's [first-run setup flow](#admin-first-run-setup) does the rest. See +[Web UI](../web-ui.md#first-run-setup-page-setup). ### `GET /setup` -*Public (gated).* Serves the wizard HTML. `404` when the wizard was never -enabled; `303 See Other` → `/admin` when enabled but an admin already exists. +*Public (gated).* Serves the setup page. `404` when it was never enabled; +`303 See Other` → `/admin` when enabled but an admin already exists. ### `POST /setup` -*Public (token-guarded).* Creates the first admin and the first library, then -starts a background scan. +*Public (token-guarded).* Creates the first admin. The page sends only the +token and the account; the library fields are still accepted so a caller that +scripts this route (an older page, the desktop manager) keeps working. | Body field | Type | Required | Notes | |---|---|---|---| | `token` | string | yes | the one-time setup token | | `username` | string | no | defaults to `"admin"` | | `password` | string | yes | admins must have a password | -| `library_name` | string | yes | | -| `library_root` | string | yes | must be an existing directory on the server | +| `library_name` | string | with `library_root` | optional; send both or neither | +| `library_root` | string | with `library_name` | must be an existing directory on the server | -Response `201`: `{ "user": { … }, "library": { … } }`. +Without the library fields it creates only the admin: `201 { "user": { … } }`. +With both, it checks the folder exists before making the admin, then creates +the library with the defaults, starts its background scan and records the +console flow's `folder` step as done (its `naming` step stays open): +`201 { "user": { … }, "library": { … } }`. | Status | Meaning | |---|---| -| `400` | validation (missing library fields, folder doesn't exist, password rules) | +| `400` | validation (only one of the library fields, the folder doesn't exist, password rules) | | `403` | invalid setup token | -| `409` | setup not available (already completed or never enabled) | +| `409` | setup not available (already completed or never enabled); with the library fields, also a library create refusal (`name_taken`, `root_overlaps`) after the admin was made, which then exists | --- diff --git a/docs-developers/server/auth-and-security.md b/docs-developers/server/auth-and-security.md index afb9abb6..2a59661a 100644 --- a/docs-developers/server/auth-and-security.md +++ b/docs-developers/server/auth-and-security.md @@ -33,7 +33,7 @@ and for password-less accounts, so response timing doesn't leak account existence. :::danger[Never log or persist a plaintext secret] -The first-run banner (and the setup wizard URL) is the **only** place a +The first-run banner (and the setup page URL) is the **only** place a plaintext credential ever appears, exactly once. Handlers return freshly-minted codes/tokens in the response body and store only the hash. ::: diff --git a/docs-developers/server/backups-and-notifications.md b/docs-developers/server/backups-and-notifications.md index e9b5eaea..390a4e96 100644 --- a/docs-developers/server/backups-and-notifications.md +++ b/docs-developers/server/backups-and-notifications.md @@ -165,7 +165,7 @@ key) or `system`. No IP address is stored. | `device` | `revoke` | | `progress` | `edit` | | `issue` | `ignore`, `unignore` | -| `settings` | `update` (details: `changes`, each `{setting, from, to}`), `support` (an answer to the console's [support card](web-ui.md#the-support-card); details: `choice` `donated` or `snoozed`, and a snooze's `returns_at`; not recorded when a donation was already stored and nothing changed) | +| `settings` | `update` (details: `changes`, each `{setting, from, to}`), `support` (an answer to the console's [support card](web-ui.md#the-support-card); details: `choice` `donated` or `snoozed`, and a snooze's `returns_at`; not recorded when a donation was already stored and nothing changed), `setup` (a `PUT /admin/setup` from the console's [first-run setup flow](web-ui.md#the-first-run-setup-flow); details: whichever of `finished`, `dismissed` and `steps` the request sent; recorded on every successful request) | | `backup` | `create`, `download`, `delete`, `restore`, `restore_cancel`, `restore_applied`, `restore_failed` | | `notify` | `create`, `update`, `delete` | | `bridge` | `code` (a pairing code made), `paired` (a Bridge exchanged a code; `via` `system`, details: `ip`), `unpair`, `job` (a job queued; details: `job`, `kind`, `library`; the target the book's path), `cancel` (details: `job`, `kind`): the [Bridge](api/reference.md#the-bridge) | diff --git a/docs-developers/server/configuration.md b/docs-developers/server/configuration.md index 5557dcac..509390fb 100644 --- a/docs-developers/server/configuration.md +++ b/docs-developers/server/configuration.md @@ -35,7 +35,16 @@ Most keys can also be changed while the server runs, from the admin console's :::note First-run **admin bootstrap** is keyed off the database (does an admin exist?), not off config-file existence - pre-supplying a `config.yaml` does -not suppress the credentials banner or the setup wizard. +not suppress the credentials banner or the setup page. + +The console's first-run setup flow is keyed off the database too: the +`server_state` key `setup_state` (no row = a fresh server, flow not finished; +an upgraded server is marked finished, see +[Data model](data-model.md#server-state)). A library declared in `config.yaml` or the +environment doesn't finish it: the flow lists it as already added +(`GET /admin/setup`'s `config_libraries`) and its naming step can still switch +that library's `metadata_source`, which survives restarts (the config sync +updates only a declared library's root and default view). ::: ## CLI flags (`cmd/audiosilo`) @@ -45,7 +54,7 @@ not suppress the credentials banner or the setup wizard. | `--data` | `./data` | Data directory: config, SQLite database, generated certs, downloaded tools | | `--ffprobe` | `ffprobe` | Path to ffprobe (durations, chapters, codec detection). **`""` disables it** - the scanner degrades to path-derived metadata and `direct_playable` defaults to true | | `--ffmpeg` | `ffmpeg` | Path to ffmpeg for on-the-fly transcoding. **`""` disables it** - `?transcode=1` returns 503 and the `transcode` capability reports false | -| `--setup` | `false` | First-run **web setup wizard**: instead of auto-creating the admin and printing credentials once, mint a one-time token and enable the guarded `/setup` page (see [Built-in web UI](web-ui.md)) | +| `--setup` | `false` | First-run **setup page**: instead of auto-creating the admin and printing credentials once, mint a one-time token and enable the guarded `/setup` page, where the admin's username and password are chosen in the browser (see [Built-in web UI](web-ui.md#first-run-setup-page-setup)). Either way the admin console's [first-run setup flow](web-ui.md#the-first-run-setup-flow) then adds the books folder and the rest on the first `/admin` visit | A bare tool name (no path separator) is resolved **next to the server executable first**, then on `$PATH`; an explicit path is used as-is. If a tool @@ -714,7 +723,7 @@ overrides). It rejects: Secure-by-default choices baked into `Default()` and first-run: TLS on (`selfsigned`) out of the box, no default passwords (credentials are minted -and printed once, or set via the guarded setup wizard), config written +and printed once, or set via the guarded setup page), config written `0600`, no trusted proxies and no CORS grants until configured. See [Auth & security](auth-and-security.md) for the app-layer hardening these feed into. @@ -730,7 +739,7 @@ command and is also available for running the server in-process. The full | `DataDir` | string | Config/database/certs directory (as `--data`) | | `FFprobePath` / `FFmpegPath` | string | Tool paths; `""` disables the tool (as the flags) | | `Log` | `*slog.Logger` | Logger; nil → default stderr text logger | -| `Setup` | bool | Select the first-run flow: `false` = auto-admin + printed banner; `true` = token-guarded `/setup` wizard | +| `Setup` | bool | Select the first-run flow: `false` = auto-admin + printed banner; `true` = token-guarded `/setup` page (the admin account only; the console's setup flow follows) | | `OnURL` | `func(url string)` | Called once at startup with the URL to open: the token-carrying `/setup#token=…` while first-run setup is pending, else `/web` when a player is available, else `/admin`. A GUI launcher uses it to open a browser | | `Bind` | string | Config override for `bind` (e.g. `"127.0.0.1:8080"`) | | `TLSMode` | string | Config override for `tls.mode` (`"off"`/`"selfsigned"`/`"autocert"`) | diff --git a/docs-developers/server/data-model.md b/docs-developers/server/data-model.md index 59f5e75a..b6f9d64d 100644 --- a/docs-developers/server/data-model.md +++ b/docs-developers/server/data-model.md @@ -4,7 +4,7 @@ description: "The SQLite schema behind audiosilo-server: the rebuildable index v --- The schema lives in `internal/store/migrations/` as numbered SQL files -(`0001_init.sql` … `0052_podcasts.sql`), embedded into the binary and +(`0001_init.sql` … `0053_setup_state.sql`), embedded into the binary and applied by `store.Open` at startup. This page documents the **resulting current schema**, noting which migration added what. @@ -882,6 +882,14 @@ Its keys: before the listener; a failure stops the start, as a failed migration does, since every series filter reads only the keys (see `books.series_key` and `book_more_series` in [The rebuildable index](#the-rebuildable-index)). +- `setup_state`: the admin console's + [first-run setup flow](web-ui.md#the-first-run-setup-flow), + `{"finished":false,"dismissed":false,"steps":{"folder":"done","naming":"skipped"}}` + (each step id in `catalog.SetupSteps`, `done` or `skipped`; an absent id is a + step not yet seen). No row means a fresh server: not finished, the flow opens + on the first console visit. `catalog.UpdateSetupState` merges a change into + it in one writer transaction; migration `0053` writes it finished and + dismissed on an upgraded server. ### The Bridge @@ -1213,6 +1221,7 @@ The migration history so far: | 0050 | `book_attachments_description` | `book_attachments.description` (`TEXT NOT NULL DEFAULT ''`): the Bridge's checked `.audiosilo/description.json` (Phase 1c), derived like the rest of the row. No data moves: a book read before it had no `description.json` in its signature (the name was unknown then), so a book that has one now has a changed signature and the next scan reads it again, without re-indexing | | 0051 | `book_attachments_chapters` | `book_attachments.chapters` (`TEXT NOT NULL DEFAULT ''`): the Bridge's checked `.audiosilo/chapters.json` (Phase 1d), derived like the rest of the row and read by `applyChapterSource` as the generated chapter source ([Generated chapters](community-chapters.md#generated-chapters)). No data moves, for the same reason as 0050: a book that has one now has a changed signature, so the next scan reads it again | | 0052 | `podcasts` | `libraries.kind`, show/hash/episode indexes, durable follows (bound to `show_id`), server feed state including the missing-secret latch, idempotent folded-job markers, and `bridge_jobs.feed_redacted` plus subscribe/recovery indexes; see [Podcasts](#podcasts) | +| 0053 | `setup_state` | the `server_state` row `setup_state` (see [Server state](#server-state)), inserted (`INSERT OR IGNORE`) finished and dismissed with no steps when the server already holds a library of any kind or more than one account that isn't a demo account or a household member (`is_demo = 0 AND managed_by IS NULL`), so an upgraded server never shows the first-run flow or its Overview card. A fresh database has neither when it runs (the first admin is made after the migrations), so it gets no row | ## SQLite choices diff --git a/docs-developers/server/overview.md b/docs-developers/server/overview.md index 8e24de27..33129a0d 100644 --- a/docs-developers/server/overview.md +++ b/docs-developers/server/overview.md @@ -69,7 +69,7 @@ startup logic belongs in `pkg/launcher`. ### `pkg/launcher` (public) The run loop: load config → open the store → wire services → first-run -bootstrap (auto-admin banner, or the token-guarded `/setup` wizard in setup mode) +bootstrap (auto-admin banner, or the token-guarded `/setup` page in setup mode; the console's first-run flow takes it from there) → sync config-declared libraries → kick off the initial background scan → serve until the context is cancelled. It is public so the server can also run in-process (see [Configuration](configuration.md#launcher-options-for-embedders)); diff --git a/docs-developers/server/scanner.md b/docs-developers/server/scanner.md index d18a835e..de96a306 100644 --- a/docs-developers/server/scanner.md +++ b/docs-developers/server/scanner.md @@ -94,7 +94,7 @@ A scan of a library runs when: |---|---|---| | Server startup (every library, once) | `startup` | `Scanner.EnqueueAll` from `launcher.Run`, after `Scanner.Start` | | The library's scan schedule says it's due | `schedule` | the scheduler goroutine (see [Scheduled scans](#scheduled-scans)) | -| Admin "Rescan" (and the setup wizard's first scan, and a newly created library) | `manual` | `POST /admin/libraries/{id}/scan`, `POST /admin/libraries`, `POST /setup` | +| Admin "Rescan" (and a newly created library, including the one the console's first-run flow adds, and the one an old-style `POST /setup` with library fields adds) | `manual` | `POST /admin/libraries/{id}/scan`, `POST /admin/libraries`, `POST /setup` | | Admin "rescan every library" | `manual` | `POST /admin/scan` (`Scanner.EnqueueAll`) | | A library's root or ignore rules changed | `change` | `handleUpdateLibrary` | | A folder override set or cleared | `change` | `handleSetFolderOverride` / `handleDeleteFolderOverride` | @@ -973,7 +973,7 @@ unavailable", from two signals: Every admin request that starts a scan (`POST …/scan`, `POST /admin/scan`, creating a library, an edit that changes the root or the ignore rules, setting or clearing a folder -override, the setup wizard) goes through the API's `startScan`, which calls +override) goes through the API's `startScan`, which calls `Scanner.Enqueue` with the requesting admin as `started_by`. The library reads as queued (or running) before the request returns, so the first status poll sees the scan even when a small library would otherwise finish between the two diff --git a/docs-developers/server/web-ui.md b/docs-developers/server/web-ui.md index ba415943..cdaa1222 100644 --- a/docs-developers/server/web-ui.md +++ b/docs-developers/server/web-ui.md @@ -1,6 +1,6 @@ --- title: Built-in web UI -description: "internal/web: the baked-in connect and setup pages, the admin console (admin-ui, embedded via internal/web/adminui), the shared SPA handler (internal/web/spa) that serves the console and the /web player, per-document CSP, the embedplayer build tag, the first-run setup wizard, and the well-known app-association files." +description: "internal/web: the baked-in connect and setup pages, the admin console (admin-ui, embedded via internal/web/adminui), the shared SPA handler (internal/web/spa) that serves the console and the /web player, per-document CSP, the embedplayer build tag, the first-run setup page and the console's first-run setup flow, and the well-known app-association files." --- The server ships three web surfaces from `internal/web`: @@ -42,7 +42,7 @@ There is no classic console any more: `/admin/classic` is just another client route, which the console answers with its "There's nothing here" page. Two related routes live in `internal/api`, not `internal/web`: the setup -wizard (`GET`/`POST /setup`, below) and - in demo mode with a player present - +page (`GET`/`POST /setup`, below) and - in demo mode with a player present - a `GET /{$}` redirect that sends the exact site root to `/web/demo` (`webDemoPath` in `api.go`) so a demo instance lands visitors straight on the instant-demo flow. @@ -348,7 +348,9 @@ CSP-sensitive work against a real build served by Go. The console's own gate is attention" card: up to six categories with something to fix, each linking to its Health queue). "Recent listening" is the stats' progress feed minus the user/book pairs that are live (`splitListening` in - `features/overview/overview-model.ts`). The side column ends with the + `features/overview/overview-model.ts`). The side column starts with the + [Finish setting up card](#the-first-run-setup-flow) while a first-run step + is still skipped, and ends with the [support card](#the-support-card) when `GET /admin/support` says it shows. - **Library > Books** - the cover grid (virtualized) or table over `GET /admin/books` (keyset pages loaded as you scroll), the "Recently added" @@ -929,6 +931,107 @@ support card", with `choice` worded as the button. The same sponsor line closes every GitHub Release's notes (the GoReleaser `release.footer`; see [Releasing](../contributing/releasing.md)). +### The first-run setup flow + +"Set up your server" (`features/setup/`, FIRST-RUN-PLAN.md Phase F1) is the +console's first-run flow: eight steps that take a new admin from the first +sign-in to a named, matched library and a paired phone. The account itself +still comes from the first-run banner or the [`/setup` page](#first-run-setup-page-setup). +It is console-only and admin-only: no capability flag, and the player never +asks. + +**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 +everywhere else. `useSetupRedirect` sends a signed-in admin there (with +`replace`) while `GET /admin/setup` reports `finished: false`, **once per +browser session** (`sessionStorage` key `audiosilo.setup.redirected`, set also +when the admin opens the flow some other way; a module variable stands in when +storage is blocked), so **Finish later** (a link to Overview) or any link out +stays where it goes. `?step=` deep-links one step; without it +`resumeStep` opens the first step with no mark (the last when all have one), +except that the folder step is asked again when its draft is gone and the +server still has no library. + +**State.** The server keeps only the flow's progress (`state`: `finished`, +`dismissed`, `steps` with each id `done` or `skipped`; an absent id is unseen) +and answers the folder preview; every step writes through the ordinary admin +APIs (`PATCH /admin/settings`, `POST /admin/libraries` with `kind: "books"`, +`PATCH /admin/libraries/{id}`) and then marks itself with +`PUT /admin/setup` (`saveSetup`). See the +[API reference](api/reference.md#admin-first-run-setup). Leaving the last step +sends `finished: true` with `finishMarks`: every step still without a mark goes +down as `skipped`, so the Overview card lists what is left. Once finished, a +step reopened from the card goes on to the next step still skipped +(`nextStep`), then back to Overview. + +**The steps** (ids in order: `folder naming metadata reach abs backups phone +bridge`, `SETUP_STEPS`), each with Skip and, after the first, Back (`StepFooter`): + +1. **folder** - a typed path (no folder picker, no new listing route) checked + by `POST /admin/setup/preview`: the counts, `same_root` as a warning, and + `path_not_absolute`, `folder_unreadable` (worded for the Docker host-path + mistake) and `root_overlaps` in plain words (`folderErrorKey`). The + envelope's `mount_hints` show as chips. Libraries the server has are listed + as already added, those named in `config_libraries` marked "From your + config". The library is **not** created here: the folder and name become a + draft, kept in `localStorage` (`audiosilo.setup.draft`, `parseDraft`) until + step 3. +2. **naming** - the preview's samples as a two-column table per book (tags, + folders), rows that differ highlighted (`compareField`, `disagreements`), + and the `metadata_source` pick (`RadioCards`) with the preview's + `suggested` side preselected and badged for the draft. A books library that + exists already (a config library, or one an older `/setup` made) keeps its + own source preselected with the suggestion as a hint; choosing the other + PATCHes it, which re-resolves its books (the same toast as Libraries). +3. **metadata** - `metadata.enabled`, `region` and `mode` (mirror's size said + up front; `mode` restart-only), then the draft becomes the library + (`useCreateDraftLibrary`: `POST /admin/libraries` with the draft's source), + and its scan starts. Skip keeps the settings but still creates the library. + From this step on `ScanStatus` shows every library scanning or queued (and + the one the flow made, once done), from `useLibraries`' `scan`; + `useScanWatcher` runs on the page since the shell isn't mounted. +4. **reach** - the browser's `location.origin` offered as `lan_url` and/or + `public_url` (`reachOffers`: a home-network host by the server's own rule, + `isHomeHost`, suits the home address first; loopback neither), the two + address fields, a reverse proxy offered for `trusted_proxies` when + `request.proxied` and the peer isn't trusted (`proxyToTrust`), a note when + the server saw another address than the browser's (`addressMismatch`), and + the TLS mode with what it means for phones plus `tls_home_http` in + self-signed mode (restart settings, badged). +5. **abs** - points to Settings > Import (new tab) and says ABS + `metadata.json` files are read. Continue records `done` only once the + import was opened, else `skipped`. +6. **backups** - `backups.schedule` and `keep` (1-365), the backup folder + shown read-only (`backups.dir` is restart-only and read-only in the + settings API), and `general.update_check`. +7. **phone** - an `api.pairSelf()` QR (single use, ten minutes; New code + refetches), the web player in a new tab with its own token + (`usePlayerTab`, only with the `web_player` capability), the ffmpeg line + from `useSystem().tools` and the household note. +8. **bridge** - what a Bridge is, a compose snippet (`bridgeCompose`, from + audiosilo-bridge's `compose.example.yaml`, mounting the first books + library's root at the same path) and the existing pairing dialog + (`usePairCode`, `PairDialog`). Once `useBridge()` reports a paired Bridge it + shows `BridgeSetupList` from the heartbeat's `setup` block instead. + Continue records `done` only with a Bridge paired. + +Every settings write goes through `unlockedPatch`, which leaves out any +setting the envelope's `locked` names (the server would refuse it with +`setting_locked`); such a setting shows a lock and "Set by your config" and is +disabled. + +**The Overview card.** `features/overview/finish-setup-card.tsx` ("Finish +setting up") shows when `finished && !dismissed` and at least one step is +`skipped` (`showFinishCard`). It lists the skipped steps, each a link to +`/setup?step=`, and **Dismiss** sends `dismissed: true` (server-wide, for +every admin). The welcome card with **Add your first library** (no libraries +at all) is separate and stays. + +Strings are the `setup.*` i18n group, in all six locales. A server upgraded +from a release without the flow never sees it (migration `0053`, see +[Data model](data-model.md#server-state)). + ### Charts The Activity charts use **Recharts** through `src/components/ui/chart.tsx`, @@ -1098,39 +1201,54 @@ Native single-binary releases bake the player in so `/web` works with no (the `.gitkeep` satisfies the embed) but exposes no player: there is no `index.html`, so `HasPlayer` is false - exactly like an empty `web_dir`. -## First-run setup wizard (`/setup`) +## First-run setup page (`/setup`) -The wizard is the `--setup` alternative to the headless first-run banner: a -browser page where a non-technical user sets the admin credentials and picks -the books folder. The page (`assets/setup.html` + `setup.js`) lives in -`internal/web`'s embedded assets, but the handlers live in -`internal/api/handlers_setup.go` because the flow creates the admin account. +The page is the `--setup` alternative to the headless first-run banner: a +browser page where a non-technical user chooses the admin's username and +password. It asks for **the account only** and then hands over to the admin +console's [first-run setup flow](#the-first-run-setup-flow), which adds the +books folder and everything else (FIRST-RUN-PLAN.md Phase F1). The page +(`assets/setup.html` + `setup.js`) lives in `internal/web`'s embedded assets, +but the handlers live in `internal/api/handlers_setup.go` because the page +creates the admin account. It is locked down three ways, so it is safe even on an exposed port: -1. **Off unless enabled**: the wizard only responds when the launcher called +1. **Off unless enabled**: the page only responds when the launcher called `API.EnableSetup(token)` with a freshly minted one-time token (18 random bytes, `pkg/launcher`). Never enabled → `GET /setup` is a plain 404, so a normal deployment exposes no setup surface at all. 2. **Self-closes the moment an admin exists** (`setupAvailable`): with a token set but an admin already present, `GET /setup` redirects to `/admin` and `POST /setup` answers `409`. Any error resolving admin state - also closes the wizard. + also closes it. 3. **Constant-time token check**: the token rides in the URL **fragment** (`/setup#token=…`), so it never appears in server or proxy logs; `setup.js` reads it client-side and includes it in the POST body, where the handler compares it with `crypto/subtle.ConstantTimeCompare`. -A successful `POST /setup` creates the admin (username defaults to `admin`; -`auth.CreateUser` enforces that admins have a password), validates that the -library folder exists on the server, creates the library, and kicks off a -background scan. If library creation fails after the admin was created, the -wizard still closes (an admin now exists) and the admin finishes in the -console - intentionally fail-safe. +`setup.js` posts `{token, username, password}`. A successful `POST /setup` +creates the admin (username defaults to `admin`; `auth.CreateUser` enforces +that admins have a password) and answers `201 {user}`; the page then shows +"Your account is ready" with a **Continue setup** link to `/admin`, whose first +visit opens the console's flow. + +`POST /setup` still accepts the old library fields, so a caller that scripts +it (an older page, the desktop manager) keeps working: with both +`library_name` and `library_root` it checks the folder exists **before** making +the admin, then creates the library with the defaults, enqueues a manual scan, +records the flow's `folder` step as done (its `naming` step stays open, so the +admin still picks the library's source) and answers `201 {user, library}`. One +of the two alone is `400`. If library creation fails after the admin was +created, the page still closes (an admin now exists) and the admin finishes in +the console - intentionally fail-safe; failing to record the `folder` step is +only logged. The launcher prints the token-carrying URL in a startup banner and also reports it through `Options.OnURL`, so an in-process caller can open it in a browser - see [Configuration](configuration.md#launcher-options-for-embedders). +The banner's last line reads "You'll choose an admin password, then the console +walks you through the rest." (`setupBanner` in `pkg/launcher`). ## Well-known app-association files diff --git a/docs-users/admin/console-tour.md b/docs-users/admin/console-tour.md index 84bfccc5..fb061e15 100644 --- a/docs-users/admin/console-tour.md +++ b/docs-users/admin/console-tour.md @@ -29,7 +29,9 @@ just want the console, go straight to `/admin`. Sign in on the **Admin sign in** card with an **administrator account** - the username and password created when you first set up the server (see [First run](../getting-started/first-run.md) if you haven't done that yet, or -don't have the credentials to hand). +don't have the credentials to hand). On a new server, signing in opens the +[Set up your server](../getting-started/first-run.md#set-up-your-server) flow +first, once per browser session until it's finished. A few things worth knowing: @@ -147,6 +149,11 @@ to keep.) See 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**. + ### The support card ![The Support AudioSilo card](/img/screenshots/admin/support-card.png) diff --git a/docs-users/getting-started/first-run.md b/docs-users/getting-started/first-run.md index 2f8de915..3c61d606 100644 --- a/docs-users/getting-started/first-run.md +++ b/docs-users/getting-started/first-run.md @@ -1,11 +1,18 @@ --- title: First run -description: "What happens the first time AudioSilo starts: the one-time credentials banner or the browser setup wizard, the data directory, and your first look at the UI." +description: "Setting up a new AudioSilo server: the admin account (printed once, or chosen in the browser with --setup), then the admin console's eight-step Set up your server flow, the Finish setting up card, and the data directory." --- ## First run -The first time the server starts with an empty data directory, it bootstraps itself. There are two first-run experiences, depending on how you start it. +A new server is set up in two parts: + +1. **Your admin account.** The server makes it for you and prints its password once, or, started with `--setup`, lets you choose the password in your browser. +2. **Set up your server.** The first time you sign in to the admin console, it walks you through eight short steps: your books folder, how your books are named, community metadata, how people reach the server, Audiobookshelf, backups, connecting a phone and adding a Bridge. You end the flow listening, not reading settings. + +## Your admin account + +The first time the server starts with an empty data directory, it bootstraps itself. There are two ways it can make the admin account, depending on how you start it. ### The default: generated credentials, printed once @@ -24,16 +31,16 @@ Started normally (including under Docker), the server creates the admin account Save these now: -- The **admin password** logs you into the admin console at `/admin` (and the player, if you want to listen as the admin account). -- The **auth code** is for pairing a listening device - enter it on the connect page and you get a QR code and links that sign a phone or browser in without typing a password. See [Connecting to a server](../listening/connecting.md). +- 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). +- The **auth code** is for pairing a listening device - enter it on the connect page and you get a QR code and links that sign a phone or browser in without typing a password. See [Connecting to a server](../listening/connecting.md). (The console's setup flow also shows a pairing code for your own phone, in [step 7](#step-7-connect-a-phone).) The banner is shown once by design: the server stores only scrambled (hashed) versions of secrets, so it *cannot* show them again later. Under Docker, `docker compose logs` shows it until the container is recreated. :::warning[Missed the banner?] -If you lost the credentials before setting anything up, the simplest fix is to start over: stop the server, delete the data directory, and start it again - a fresh first run prints a fresh banner. Only do this before you've added users or listening progress you care about, because the data directory is where those live. +If you lost the credentials before setting anything up, the simplest fix is to start over: stop the server, delete the data directory, and start it again - a fresh first run prints a fresh banner. Only do this before you've added users or listening progress you care about, because the data directory is where those live. [Troubleshooting](../troubleshooting.md#i-lost-the-first-run-admin-password) has gentler options for later. ::: -### The alternative: the browser setup wizard (`--setup`) +### The alternative: choose the password in your browser (`--setup`) Start the binary with `--setup` and the server skips creating an admin. Instead it prints a one-time link: @@ -44,25 +51,135 @@ Start the binary with `--setup` and the server skips creating an admin. Instead Open this URL in your browser to finish setting up: https://localhost:8080/setup#token=... -------------------------------------------------------- - You'll choose an admin password and your books folder. + You'll choose an admin password, then the console walks you through the rest. ======================================================== ``` -Open the link and a short wizard walks you through everything the banner would have generated for you: +Open the link and choose the **admin username** (it defaults to `admin`) and an **admin password** you'll actually remember (at least 8 characters), then press **Create account**: + +![The --setup page: create your admin account](/img/screenshots/server/setup-wizard.png) -![The first-run setup wizard](/img/screenshots/server/setup-wizard.png) +That is all this page asks. Once the account exists it says **Your account is ready** and hands you over to the admin console, where the books folder and everything else is set up: -- Choose the **admin username** (defaults to `admin`) and an **admin password** you'll actually remember. -- Name your first **library** and enter the **full path to your books folder** on the server's disk. +![Your account is ready, with Continue setup](/img/screenshots/server/setup-wizard-done.png) -When you finish, the wizard offers buttons straight into the admin console and the web player. +**Continue setup** opens the admin console's sign-in; sign in with the account you just made and the [Set up your server](#set-up-your-server) flow opens. -A few things worth knowing about how the wizard is secured: +A few things worth knowing about how this page is secured: - The secret token rides in the part of the URL after the `#`, which browsers never send to servers - so it can't leak into logs. The link only works with the token. -- The wizard **shuts itself off the moment an admin account exists**. After setup - or on a server that was set up the normal way - `/setup` simply doesn't exist. +- The page **shuts itself off the moment an admin account exists**. After setup - or on a server that was set up the normal way - `/setup` simply sends you to the admin console, and on a server never started with `--setup` it doesn't exist at all. + +## Set up your server + +The first time you open the admin console (`/admin`) on a new server, it takes you to **Set up your server**: a page of its own, with the steps listed down the side. + +![Step 1 of Set up your server: the books folder](/img/screenshots/setup/folder.png) + +How it works: + +- **Every step has Skip, and every step after the first has Back.** Nothing is required; a skipped step is waiting for you later on the Overview (see [Finish setting up](#the-finish-setting-up-card)). +- **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. + +A server that was already in use before this flow existed (it had a library, or more than one account) never shows it. + +### Step 1: Where are your books? + +Type the folder on the server that holds your audiobooks and press **Check folder** (the screenshot above). AudioSilo checks the folder is there and readable, and shows what it found - the audio files, how many **book folders** and how many **single-file books** - before anything is added. + +- **The path as the server sees it.** In Docker that's the path *inside* the container: the right-hand side of the volume, like `/audiobooks` - not the folder on your computer or NAS. Typing the host path is the usual first-run mistake, and this check catches it: the server says the folder "isn't there, or the server can't read it". +- **Folders mounted into the server** (on Linux, including Docker) are offered as buttons under the field; pressing one checks it. +- **No audio files found here** means the folder exists but holds no books AudioSilo recognizes - usually the wrong folder. +- A folder another library already uses gets a warning (adding it again makes a second library with the same books), and a folder inside, or around, a [podcast library's](../admin/podcasts.md) folder is refused. +- **Library name** defaults to the folder's name; it's shown in the apps and the console, and you can rename it later. + +Libraries the server already has are listed under **Already added**, the ones from your `config.yaml` or environment marked **From your config**. With a library already there, you can leave the field empty and continue. + +The library isn't created yet: that happens at the end of step 3, once its naming and region are chosen, so its very first scan uses them. Until then the folder is remembered by this browser. + +### Step 2: How are your books named? + +![Step 2: sample books named from tags and from folders](/img/screenshots/setup/naming.png) + +Book details - title, author, narrator, series and number - can come from the files' **tags** or from the **folder names**. This step reads up to eight books spread across your folder and shows each one both ways, side by side: + +- Rows where the two disagree are highlighted, and a field neither gives reads *none*. The line at the top counts the blank fields each way. +- A book with an Audiobookshelf `metadata.json` shows a **metadata.json** badge: that file is read over both. +- The side with fewer blanks is preselected and marked **Suggested**: **The files' tags first** suits files that are tagged well (most bought audiobooks are), **Folder names first** suits folders that follow `Author / Series / 01 - Title`. Either way, the other one fills what the first leaves empty. + +This choice decides every book's names, and changing it later re-reads the whole library, so it's worth a look now. [Organizing your library](./organizing-your-library.md#where-titles-authors-and-series-come-from) explains the two in more detail; the same setting is **Book details come from** on each [library](../admin/libraries.md#where-book-details-come-from). + +For a library the server already has (one from your config, say), the step shows its current choice. Choosing the other re-reads its books' details straight away (your own edits stay). + +### Step 3: Community metadata + +![Step 3: community metadata, the marketplace and a local copy](/img/screenshots/setup/metadata.png) + +[Community metadata](../admin/server.md#settings) adds descriptions, series and covers from the AudioSilo metadata service. Its lookups are the server's main traffic to the internet, so it's your choice: + +- **Look up community metadata**: on or off. Off, nothing about your books leaves the server for it. +- **Audible marketplace**: the store you buy from. The wrong one matches books to the wrong edition. +- **Metadata source**: ask the service for each book, or **keep a local copy** (about 1.8 GB on disk and about 450 MB downloaded a day, so no book is looked up over the internet; it applies after a restart). See [Keeping a local copy](../admin/server.md#keeping-a-local-copy). + +**Continue** - or **Skip**, which keeps these settings as they are - adds your library and starts its first scan. From here on the scan's progress shows above each step, and you can carry on while it runs. + +### Step 4: How will people reach the server? + +![Step 4: the server's addresses and HTTPS](/img/screenshots/setup/reach.png) + +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). +- **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. + +### Step 5: Coming from Audiobookshelf? + +![Step 5: the Audiobookshelf import](/img/screenshots/setup/abs.png) + +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. + +### Step 6: Backups and updates + +![Step 6: the backup schedule, how many to keep, and the update check](/img/screenshots/setup/backups.png) + +- **Back up**: when the database (listening progress, edits, accounts) is copied. The default is every day at 03:00, in the server's time zone. +- **Scheduled backups to keep**: the newest 7 by default (1 to 365). +- **Folder**: where backups go. It's shown here but set only in `config.yaml` (`backups.dir`) or `AUDIOSILO_BACKUP_DIR`; copy backups to another disk now and then. See [Backups](../admin/backups.md). +- **Check for new versions**: once a day the server asks GitHub for the newest release. Only the request itself is sent - nothing about your library or people. + +### Step 7: Connect a phone + +![Step 7: a pairing QR code for your phone](/img/screenshots/setup/phone.png) + +Scan the code with your phone's camera: it opens the 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). + +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. +- a reminder that each person can add their [household members](../listening/household.md) from **Me > Household** in the player. + +### Step 8: Add a Bridge + +![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. + +**Finish setup** (or **Skip and finish**) ends the flow and takes you to the Overview. Any step you never reached counts as skipped. + +## 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). -### What lives in the data directory +## What lives in the data directory The `--data` directory (mounted at `/data` under Docker) is the server's home. After the first run it contains: @@ -74,7 +191,7 @@ The `--data` directory (mounted at `/data` under Docker) is the server's home. A Your audiobooks are **never** copied in here. The books folder you point a library at stays untouched, and stays the source of truth - see [Organizing your library](./organizing-your-library.md). -### Your first look around +## Your first look around Open the server's address in a browser (with the defaults, `https://your-server:8080` - expect a certificate warning until you've read [Remote access](./remote-access.md)). @@ -82,8 +199,8 @@ The front page is the **connect page**. It's public and deliberately minimal: an ![The connect page](/img/screenshots/server/connect-page.png) -The **admin console** lives at `/admin`. Log in with the admin username and password from the banner or the wizard. +The **admin console** lives at `/admin`. Sign in with the admin username and password from the banner or the `--setup` page; on a new server, it opens [Set up your server](#set-up-your-server) first. ![The admin console login](/img/screenshots/admin/login.png) -From here you manage libraries, people, invites and sharing - take the [console tour](../admin/console-tour.md) next, then add a library if the wizard didn't already create one (a server with no libraries greets you with an **Add your first library** button). +From here you manage libraries, people, invites and sharing - take the [console tour](../admin/console-tour.md) next. A server with no libraries (because you skipped the books folder) greets you with an **Add your first library** button on the Overview. diff --git a/docs-users/getting-started/install-binary.md b/docs-users/getting-started/install-binary.md index 80e1bef0..499c508e 100644 --- a/docs-users/getting-started/install-binary.md +++ b/docs-users/getting-started/install-binary.md @@ -35,22 +35,22 @@ In short: on the common path there is nothing to install, and if the download ca ./audiosilo --data ./data ``` -The very first start creates an admin account and prints the credentials **once** - save them. Then open `https://localhost:8080` (the default is HTTPS with a self-signed certificate, so expect a browser warning). See [First run](./first-run.md) for the full walkthrough. +The very first start creates an admin account and prints the credentials **once** - save them. Then open `https://localhost:8080` (the default is HTTPS with a self-signed certificate, so expect a browser warning). Sign in to the admin console at `/admin` and it walks you through setting up the server. See [First run](./first-run.md) for the full walkthrough. -Prefer a guided setup in the browser instead of a printed password? Start with the setup wizard: +Prefer to choose the admin password in the browser instead of a printed one? Start with `--setup`: ```bash ./audiosilo --data ./data --setup ``` -Instead of creating an admin for you, the server prints a one-time link to a browser wizard where you choose the admin password and point it at your books folder. Details in [First run](./first-run.md). +Instead of creating an admin for you, the server prints a one-time link to a page where you choose the admin username and password. Either way, the admin console then walks you through the rest - your books folder, how books are named, connecting a phone - the first time you sign in. Details in [First run](./first-run.md). Useful flags: | Flag | What it does | | --- | --- | | `--data ` | Where config, database and certificates live (default `./data`). | -| `--setup` | First run opens a browser setup wizard instead of printing generated credentials. | +| `--setup` | First run prints a one-time link to choose the admin password in the browser, instead of printing generated credentials. | | `--ffmpeg ` | Explicit path to ffmpeg; `""` disables transcoding. | | `--ffprobe ` | Explicit path to ffprobe; `""` disables duration/chapter extraction. | diff --git a/docs-users/getting-started/install-unraid.md b/docs-users/getting-started/install-unraid.md index a52ecf96..b890f9d1 100644 --- a/docs-users/getting-started/install-unraid.md +++ b/docs-users/getting-started/install-unraid.md @@ -43,7 +43,7 @@ Click the AudioSilo icon and choose **WebUI**. By default the server speaks HTTP - **`/admin`** - the admin console. Log in as `admin` with the printed password. - **`/web`** - the web player. -In the admin console, click **Add your first library** and point it at **`/audiobooks`** (the path inside the container, not the `/mnt/user/...` share). See [Libraries](../admin/libraries.md) for the details, then [connect a phone or browser](../listening/connecting.md). +The first time you sign in to the admin console, it opens **Set up your server**. Its first step asks for your books folder: type **`/audiobooks`** (the path inside the container, not the `/mnt/user/...` share) and press **Check folder**. [First run](./first-run.md#set-up-your-server) walks through it and the steps after it. Skipped the folder? Add it later from **Add your first library**; see [Libraries](../admin/libraries.md). Then [connect a phone or browser](../listening/connecting.md). ### HTTPS and reverse proxies diff --git a/docs-users/getting-started/quickstart-docker.md b/docs-users/getting-started/quickstart-docker.md index e6241e71..95a7c687 100644 --- a/docs-users/getting-started/quickstart-docker.md +++ b/docs-users/getting-started/quickstart-docker.md @@ -1,6 +1,6 @@ --- title: Quick start with Docker -description: "Run the AudioSilo server with Docker Compose: the image, the volumes, first-run credentials, and your first library." +description: "Run the AudioSilo server with Docker Compose: the image, the volumes, first-run credentials, and setting up your first library in the console." --- ## Quick start with Docker @@ -88,11 +88,11 @@ Your browser will warn about the certificate the first time - that's expected wi - **`/admin`** - the admin console. Log in as `admin` with the printed password. - **`/web`** - the web player, bundled with the image. -### 4. Add your first library +### 4. Set up your server -In the admin console, click **Add your first library** and point it at the path **inside the container** - `/library` with the compose file above (the **Browse** button lists the container's folders). The library is browsable immediately; the scanner fills in metadata, covers and durations in the background. See [Libraries](../admin/libraries.md) for the details. +The first time you sign in to the admin console, it opens **Set up your server**: eight short steps, each one skippable. Its first step asks for your books folder: type the path **inside the container** - `/library` with the compose file above (mounted folders are offered as buttons) - and press **Check folder** to see what it finds. The library is browsable as soon as it's added; the scanner fills in metadata, covers and durations in the background. [First run](./first-run.md#set-up-your-server) walks through every step. -Then connect a phone or browser and start listening - see [Connecting to a server](../listening/connecting.md). +Skipped the folder? Add the library later from the console's **Add your first library** button - see [Libraries](../admin/libraries.md). Then connect a phone or browser and start listening - see [Connecting to a server](../listening/connecting.md). ### Updating diff --git a/docs-users/getting-started/remote-access.md b/docs-users/getting-started/remote-access.md index 9f405b22..a578572a 100644 --- a/docs-users/getting-started/remote-access.md +++ b/docs-users/getting-started/remote-access.md @@ -121,7 +121,7 @@ A request that reaches AudioSilo through a reverse proxy never gives the server Being internet-facing for non-experts is the server's first design priority, and the protections are always on - there is nothing to remember to enable: -- **No default passwords.** The admin credential is randomly generated on first run (or chosen by you in the setup wizard); there is no factory password to forget to change. +- **No default passwords.** The admin credential is randomly generated on first run (or chosen by you on the one-time `--setup` page); there is no factory password to forget to change. - **No sign-ups.** Nobody can sign themselves up. Accounts are created by the admin, and devices join via short-lived invite codes (by default good for 5 uses and one day). See [People and invites](../admin/users-and-invites.md). Someone who already has an account can also add the kids and grown-ups they live with as [household members](../listening/household.md), signed in by them and never through a public page, within limits [the admin sets](../admin/users-and-invites.md#household-members). - **Rate limiting and lockout.** Requests are rate-limited per visitor address (covers and audio per signed-in device instead, so browsing a large library is never throttled), and repeated failed logins or invite-code guesses trigger a brute-force lockout. - **Secrets are stored scrambled.** Passwords use modern, deliberately slow hashing (argon2id); sign-in tokens and invite codes are stored only as hashes, and every token can be revoked. diff --git a/docs-users/index.md b/docs-users/index.md index 88306c66..c1023466 100644 --- a/docs-users/index.md +++ b/docs-users/index.md @@ -26,7 +26,7 @@ Where you actually listen. One app ships as a web player (served by your own ser ## What makes it a good home for your audiobooks -- **Safe to expose to the internet.** No default passwords - credentials are generated on first run or chosen by you in a setup wizard. Nobody can sign themselves up (accounts come from an admin's invite, or from someone with an account adding a member of their household), log-in attempts are rate limited with brute-force lockout, and HTTPS is on by default. See [Remote access](./getting-started/remote-access.md). +- **Safe to expose to the internet.** No default passwords - credentials are generated on first run or chosen by you on a one-time setup page. Nobody can sign themselves up (accounts come from an admin's invite, or from someone with an account adding a member of their household), log-in attempts are rate limited with brute-force lockout, and HTTPS is on by default. See [Remote access](./getting-started/remote-access.md). - **Fast, even with huge libraries.** Search and browsing are built on an indexed database that stays quick at thousands of books, and your library is browsable immediately after adding it - no long import step. - **Your files stay ordinary files.** AudioSilo never converts, moves or rewrites your audiobooks. The folder on disk is the source of truth; the server's database is just a rebuildable index. Rename or move a book and your listening progress follows it. See [Organizing your library](./getting-started/organizing-your-library.md). diff --git a/screenshots/README.md b/screenshots/README.md index b91abcf9..5da6ac42 100644 --- a/screenshots/README.md +++ b/screenshots/README.md @@ -17,8 +17,8 @@ npm install && npx playwright install chromium # first time only `run.sh` builds the sibling `audiosilo-server`, seeds a small public-domain LibriVox library (cached in `.cache/library`; `MAX_FILES=3` chapter files per book keeps it ~100 MB), starts a demo-mode server on `:8790` serving the -frontend's web export (plus a `--setup` instance on `:8791` for the wizard -shot), builds the `audiosilo-meta` data artifact (composed with +frontend's web export (plus a `--setup` instance on `:8791` for the first-run +setup shots), builds the `audiosilo-meta` data artifact (composed with `audiosilo-meta-community`) + site and starts a `metaserve` on `:8795` for the meta-site shots, then runs the Playwright captures and backfills placeholders. `SKIP_META=1` skips the meta stack (for a web/admin-only run). @@ -37,6 +37,22 @@ 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). +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 +when a setup shot is wanted). It creates the admin on the `--setup` page, then +runs the console's eight steps through their real UI and ends on the Overview's +**Finish setting up** card; its header comment has the details (the generated +books folder, `SETUP_BOOKS`, `SETUP_HOST`). It needs **ffmpeg** on PATH, and on +a macOS host the folder step shows no mounted-folder suggestions (they are read +on Linux only). + +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. + 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 the network): `admin/book-match.png` shows the match dialog's candidates, and diff --git a/screenshots/capture-admin.mjs b/screenshots/capture-admin.mjs index 1cf07619..6a037a48 100644 --- a/screenshots/capture-admin.mjs +++ b/screenshots/capture-admin.mjs @@ -2,7 +2,6 @@ // Requires a running server (run.sh) and env: // AS_ORIGIN server origin (default http://127.0.0.1:8790) // ADMIN_PASSWORD the first-run admin password (parsed from the log by run.sh) -// SETUP_URL optional: a second --setup server's wizard URL (with #token) // MIRROR_ORIGIN optional: a third server in metadata mirror mode with a seeded // local copy (run.sh starts it when it built the meta artifact) // MIRROR_PASSWORD that server's first-run admin password @@ -45,11 +44,14 @@ import { addHousehold, apiClient, around, + fitViewportToPage, isoAgo, pathQuery, + silence, sleep, shoot, step, + submitSignIn, wanted, DESKTOP_CONTEXT, } from './lib.mjs'; @@ -57,7 +59,6 @@ import { const ORIGIN = (process.env.AS_ORIGIN || 'http://127.0.0.1:8790').replace(/\/$/, ''); const ADMIN = `${ORIGIN}/admin`; const PASSWORD = process.env.ADMIN_PASSWORD; -const SETUP_URL = process.env.SETUP_URL || ''; 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')); @@ -91,6 +92,17 @@ const login = await api(null, 'POST', '/auth/login', {username: 'admin', passwor const token = login.token; console.log(' ✓ admin login'); +// A fresh server sends its admin to the first-run flow on the first console +// visit of every browser session; the flow has shots of its own +// (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.) +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}`), + ); +await markSetUp(api, token); + await step('provision listener + share', async () => { const libs = await api(token, 'GET', '/admin/libraries'); library = (libs.libraries || libs || [])[0] ?? null; @@ -412,14 +424,6 @@ const callOfTheWild = async () => { if (!book) throw new Error('no seeded Call of the Wild'); return book; }; -// Makes the viewport as tall as the page (between min and max), so a long card -// or every card is in the shot; the caller restores DESKTOP_CONTEXT.viewport. -const fitViewportToPage = async (p, min, max, settle = 800) => { - const height = await p.evaluate(() => document.documentElement.scrollHeight); - await p.setViewportSize({width: 1440, height: Math.min(Math.max(height, min), max)}); - await sleep(settle); -}; - await step('sign-in page', async () => { await page.goto(`${ADMIN}/`, {waitUntil: 'networkidle', timeout: 45000}); await page.getByRole('heading', {name: 'Admin sign in'}).waitFor({timeout: 15000}); @@ -429,9 +433,7 @@ await step('sign-in page', async () => { // Signs in as admin from the console's sign-in form (already open on `p`). const signIn = async (p, password) => { - await p.getByLabel('Username', {exact: true}).fill('admin'); - await p.getByLabel('Password', {exact: true}).fill(password); - await p.getByRole('button', {name: 'Sign in', exact: true}).click(); + await submitSignIn(p, password); await p.locator('h1.display').first().waitFor({timeout: 15000}); }; @@ -670,15 +672,6 @@ await step('overview on a phone', async () => { // library. Everything lands under INBOX_DIR (gitignored .cache); the seeded // library is only read (copy-on-write clones where the filesystem supports it). let inbox = null; -const ffmpeg = (args) => - spawnSync('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', ...args], {stdio: 'ignore'}).status === 0; -// Silent mono audio: cheap to encode even when it is hours long. -const silence = (file, seconds, meta = {}, codec = ['-c:a', 'libmp3lame', '-b:a', '8k']) => - ffmpeg([ - '-f', 'lavfi', '-i', 'anullsrc=r=8000:cl=mono', '-t', String(seconds), - ...Object.entries(meta).flatMap(([k, v]) => ['-metadata', `${k}=${v}`]), - ...codec, file, - ]); await step('provision an Inbox library with issues', async () => { await rm(INBOX_DIR, {recursive: true, force: true}); @@ -845,6 +838,7 @@ await step('health: system in mirror mode', async () => { // run.sh's dead proxy fails): stop at once, with its metadata switched off. const mapi = apiClient(MIRROR_ORIGIN); const {token: mtoken} = await mapi(null, 'POST', '/auth/login', {username: 'admin', password: MIRROR_PASSWORD}); + await markSetUp(mapi, mtoken); for (const until = Date.now() + 30000; ; await sleep(500)) { const m = await mapi(mtoken, 'GET', '/admin/meta/mirror'); if (m.state === 'ready') break; @@ -1524,15 +1518,6 @@ await step('connect page', async () => { await p2.close(); }); -await step('setup wizard', async () => { - if (!SETUP_URL) throw new Error('SETUP_URL not set (run.sh starts the --setup instance)'); - const p3 = await ctx.newPage(); - await p3.goto(SETUP_URL, {waitUntil: 'networkidle', timeout: 45000}); - await sleep(1500); - await shoot(p3, 'server/setup-wizard.png'); - await p3.close(); -}); - await ctx.close(); await browser.close(); hook.closeAllConnections(); diff --git a/screenshots/capture-setup.mjs b/screenshots/capture-setup.mjs new file mode 100644 index 00000000..9e298129 --- /dev/null +++ b/screenshots/capture-setup.mjs @@ -0,0 +1,171 @@ +// Captures the first-run setup screenshots: the --setup page (the account, then +// its hand-over to the console) and every step of the admin console's "Set up +// your server" flow, then the Overview's "Finish setting up" card. Requires the +// --setup server run.sh starts (a fresh data dir, no admin yet) and env: +// SETUP_URL that server's one-time wizard URL (http://127.0.0.1:/setup#token=...) +// SETUP_BOOKS optional: where to build the books folder the flow adds +// (default .cache/setup/Audiobooks; rebuilt on every run) +// SETUP_HOST optional: the name the browser uses for the server (default +// audiosilo.local, mapped to 127.0.0.1 inside the browser only), +// so the address step treats it as a home-network address and +// offers it as the home address, as it would on a real LAN +// +// The books folder is four tiny books of generated silence (ffmpeg on PATH) laid +// out as Author / Series / N - Title, whose tags are poor or missing (a generic +// "Track 01" title, an author written surname first, no tags at all), so the +// naming step shows the two sources disagreeing and suggests the folders. The +// flow is driven through its real UI with the exact English labels of the +// server's admin-ui/src/i18n/locales/en.json (setup.*): a renamed button there +// needs the same rename here. Every step is continued except the +// Audiobookshelf step (left unopened, so it counts as skipped) and the Bridge +// step (no Bridge, so "Skip and finish"), which leaves the Overview card two +// steps to list. Run once per fresh --setup server: the account it makes +// closes the wizard. +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, +} 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'; +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.'); + process.exit(0); +} +if (!SETUP_URL) { + console.error('capture-setup: SETUP_URL is required (run.sh starts the --setup server)'); + process.exit(1); +} + +// The wizard's URL with the browser's name for the server. +const wizard = new URL(SETUP_URL); +wizard.hostname = HOST; + +// ── The books folder ──────────────────────────────────────────────────────── +// Each book: its folder under BOOKS and the tags its two files carry. +const FIXTURE = [ + // Tagged well, apart from the series the folders know. + {dir: ['Arthur Conan Doyle', 'Sherlock Holmes', '1 - A Study in Scarlet'], + tags: {album: 'A Study in Scarlet', artist: 'Arthur Conan Doyle', composer: 'David Clarke'}}, + // Tagged another way: the album as a shop sells it, the author surname first. + {dir: ['Arthur Conan Doyle', 'Sherlock Holmes', '2 - The Sign of the Four'], + tags: {album: 'The Sign of Four (Unabridged)', artist: 'Doyle, Arthur Conan', composer: 'David Clarke'}}, + // Only a generic track title. + {dir: ['L. Frank Baum', 'Oz', '1 - The Wonderful Wizard of Oz'], tags: {title: 'Track 01'}}, + // No tags at all. + {dir: ['Jane Austen', 'Pride and Prejudice'], tags: {}}, +]; +await step('build the setup books folder', async () => { + await rm(BOOKS, {recursive: true, force: true}); + let ok = true; + for (const b of FIXTURE) { + const folder = path.join(BOOKS, ...b.dir); + await mkdir(folder, {recursive: true}); + for (const n of [1, 2]) ok = silence(path.join(folder, `0${n}.mp3`), 4, b.tags) && ok; + } + if (!ok) throw new Error('ffmpeg missing or failed: the folder and naming steps will find nothing'); + console.log(` ✓ books folder (${FIXTURE.length} books) at ${BOOKS}`); +}); + +// ── Capture ──────────────────────────────────────────────────────────────── +const browser = await chromium.launch({args: [`--host-resolver-rules=MAP ${HOST} 127.0.0.1`]}); +const ctx = await browser.newContext(DESKTOP_CONTEXT); +const page = await ctx.newPage(); + +// Makes the viewport as tall as the page (between min and max) for one shot. +const tall = async (file, min = 900, max = 2400) => { + await fitViewportToPage(page, min, max, 600); + await shoot(page, file); + await page.setViewportSize(DESKTOP_CONTEXT.viewport); +}; +const heading = (name) => page.getByRole('heading', {level: 1, name, exact: true}); +const button = (name) => page.getByRole('button', {name, exact: true}); +// Leaves the current step with `action` (Continue, Skip, ...) and waits for the next one. +const leave = async (action, nextTitle) => { + await button(action).click(); + await heading(nextTitle).waitFor({timeout: 30000}); + await page.mouse.move(0, 0); + await sleep(800); +}; + +await step('the --setup page', async () => { + await page.goto(wizard.href, {waitUntil: 'networkidle', timeout: 45000}); + await page.locator('#setup-form').waitFor({timeout: 15000}); + await sleep(800); + await shoot(page, 'server/setup-wizard.png'); + await page.locator('#password').fill(PASSWORD); + await page.locator('#setup-submit').click(); + await page.locator('#setup-done').waitFor({state: 'visible', timeout: 15000}); + await sleep(600); + await shoot(page, 'server/setup-wizard-done.png'); +}); + +// The flow from here on: each step needs the one before it. +await step('the console flow', async () => { + await page.getByRole('link', {name: 'Continue setup', exact: true}).click(); + await submitSignIn(page, PASSWORD); + // The console sends a server that hasn't finished setting up to the flow. + 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 button('Check folder').click(); + await page.getByText(/^Found \d+ audio files$/).waitFor({timeout: 30000}); + await page.getByLabel('Library name', {exact: true}).fill('Audiobooks'); + await page.mouse.move(0, 0); + await sleep(600); + await shoot(page, 'setup/folder.png'); + await leave('Continue', 'How are your books named?'); + + // 2. Naming: the samples side by side, the folders suggested. + await page.getByRole('list', {name: 'Sample books'}).waitFor({timeout: 30000}); + await sleep(600); + await tall('setup/naming.png'); + await leave('Continue', 'Community metadata'); + + // 3. Metadata, then the library is added and its first scan starts. + await shoot(page, 'setup/metadata.png'); + await leave('Continue', 'How will people reach the server?'); + + // 4. Addresses: the scan bar above the step, once the scan has finished. + await page.getByText(/^\d+ books ready$/).waitFor({timeout: 60000}); + // ... and the "Added Audiobooks" toast has gone. + await page.getByText('Added Audiobooks', {exact: true}).waitFor({state: 'hidden', timeout: 20000}).catch(() => {}); + await sleep(600); + await tall('setup/reach.png'); + await leave('Continue', 'Coming from Audiobookshelf?'); + + // 5. Audiobookshelf: the import never opened, so Continue records it skipped. + await shoot(page, 'setup/abs.png'); + await leave('Continue', 'Backups and updates'); + + // 6. Backups and updates. + await shoot(page, 'setup/backups.png'); + await leave('Continue', 'Connect a phone'); + + // 7. Phone: the pairing QR code drawn. + await page.getByRole('img', {name: 'Pairing QR code for your phone'}).waitFor({timeout: 20000}); + await sleep(600); + await shoot(page, 'setup/phone.png'); + await leave('Continue', 'Add a Bridge'); + + // 8. Bridge: none paired, so the flow ends with "Skip and finish". + await tall('setup/bridge.png'); + await button('Skip and finish').click(); + + // The Overview card lists the two skipped steps. + const card = page.locator('[aria-labelledby="finish-setup-heading"]'); + await card.waitFor({timeout: 20000}); + await card.getByRole('link', {name: 'Add a Bridge'}).waitFor({timeout: 10000}); + await sleep(1500); // the toast fades, the covers load + await shoot(page, 'setup/finish-card.png', {clip: around([await card.boundingBox()])}); +}); + +await ctx.close(); +await browser.close(); +console.log('capture-setup: done.'); diff --git a/screenshots/lib.mjs b/screenshots/lib.mjs index 4e031da3..3c1e4457 100644 --- a/screenshots/lib.mjs +++ b/screenshots/lib.mjs @@ -38,6 +38,33 @@ export const DESKTOP_CONTEXT = { reducedMotion: 'reduce', }; +// 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. +export const fitViewportToPage = async (p, min, max, settle = 800) => { + const height = await p.evaluate(() => document.documentElement.scrollHeight); + await p.setViewportSize({width: DESKTOP_CONTEXT.viewport.width, height: Math.min(Math.max(height, min), max)}); + await sleep(settle); +}; + +// 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'); + await p.getByLabel('Password', {exact: true}).fill(password); + await p.getByRole('button', {name: 'Sign in', exact: true}).click(); +}; + +// Fixture audio: ffmpeg(args) is true when ffmpeg ran and succeeded, and +// silence() writes silent mono audio, cheap to encode even when it is hours long. +export const ffmpeg = (args) => + spawnSync('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', ...args], {stdio: 'ignore'}).status === 0; +export const silence = (file, seconds, meta = {}, codec = ['-c:a', 'libmp3lame', '-b:a', '8k']) => + ffmpeg([ + '-f', 'lavfi', '-i', 'anullsrc=r=8000:cl=mono', '-t', String(seconds), + ...Object.entries(meta).flatMap(([k, v]) => ['-metadata', `${k}=${v}`]), + ...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 = {}) => { diff --git a/screenshots/manifest.mjs b/screenshots/manifest.mjs index 5b10bf09..5cc31554 100644 --- a/screenshots/manifest.mjs +++ b/screenshots/manifest.mjs @@ -9,6 +9,7 @@ // `capture` says which script produces it: // 'web' - capture-web.mjs (web player via a warmed demo session) // '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) export const SHOTS = [ @@ -133,7 +134,19 @@ export const SHOTS = [ {file: 'admin/activity-sessions.png', capture: 'admin', title: 'Admin - Sessions', hint: 'Activity > Sessions: the session table, newest first'}, {file: 'admin/activity-year.png', capture: 'admin', title: 'Admin - Year in listening', hint: 'Activity > Year in listening: this year\'s story and calendar (1440x1100)'}, {file: 'server/connect-page.png', capture: 'admin', title: 'Connect page', hint: 'public connect page (auth-code box / QR)'}, - {file: 'server/setup-wizard.png', capture: 'admin', title: 'Setup wizard', hint: 'first-run --setup wizard page'}, + + // ── First-run setup (capture-setup.mjs: a fresh --setup server; desktop 1440 wide, dark) ── + {file: 'server/setup-wizard.png', capture: 'setup', title: 'Setup page', hint: "the --setup page: Welcome - let's set up your server, the admin username and password, Create account"}, + {file: 'server/setup-wizard-done.png', capture: 'setup', title: 'Setup page - account ready', hint: 'the --setup page after Create account: Your account is ready, and Continue setup into the console'}, + {file: 'setup/folder.png', capture: 'setup', title: 'Setup - Books folder', hint: 'step 1 of the console\'s Set up your server flow: the typed books folder checked (Found 8 audio files, 4 book folders) and the library name'}, + {file: 'setup/naming.png', capture: 'setup', title: 'Setup - Book names', hint: 'step 2: four sample books named from tags and from folders side by side (the disagreeing rows marked), Folder names first preselected as Suggested (as tall as the page)'}, + {file: 'setup/metadata.png', capture: 'setup', title: 'Setup - Community metadata', hint: 'step 3: community metadata on, the region, the service or a local copy, and the line saying Continue adds the library'}, + {file: 'setup/reach.png', capture: 'setup', title: 'Setup - Addresses', hint: 'step 4, the library\'s scan done above it: the address the browser uses (a .local name) with Use as the home / public address, the two address fields, HTTPS: Off set by the config'}, + {file: 'setup/abs.png', capture: 'setup', title: 'Setup - Audiobookshelf', hint: 'step 5: what the Audiobookshelf import brings across, metadata.json read as it is, Open the import'}, + {file: 'setup/backups.png', capture: 'setup', title: 'Setup - Backups and updates', hint: 'step 6: the backup schedule (daily at 03:00), how many to keep (7), the backup folder (shown, set in config) and the update check'}, + {file: 'setup/phone.png', capture: 'setup', title: 'Setup - Connect a phone', hint: 'step 7: the pairing QR code, New code, Open the web player, the ffmpeg line and the household note'}, + {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"}, // ── 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'}, diff --git a/screenshots/run.sh b/screenshots/run.sh index da9aec8a..ad1294df 100755 --- a/screenshots/run.sh +++ b/screenshots/run.sh @@ -4,11 +4,12 @@ # 1. builds the AudioSilo server from the sibling repo, # 2. seeds a small public-domain library (LibriVox; cached in .cache/library), # 3. starts a demo-mode server (port 8790) serving the frontend's web export, -# plus a second --setup instance (port 8791) for the wizard shot, +# plus a second --setup instance (port 8791) for the first-run setup shots +# (the --setup page and the console's "Set up your server" flow), # 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 meta site), +# + the first-run setup flow + the meta site), # 6. backfills placeholders for anything not captured. # # Prereqs: Go 1.26+, Node 24, ffmpeg/ffprobe, sqlite3, openssl, `npm install` + @@ -194,11 +195,22 @@ AUDIOSILO_WEB_DIR="$FRONTEND/dist" "$SERVER_BIN" --data "$DATA" \ > "$CACHE/server.log" 2>&1 & MAIN_PID=$! -# The --setup instance only serves the admin captures' wizard shot. -if [ "${SKIP_ADMIN:-0}" != "1" ]; then +# The --setup instance serves capture-setup.mjs: the --setup page, then the +# 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 + WANT_SETUP=1 +fi +if [ "$WANT_SETUP" = 1 ]; then echo "==> starting --setup server on :$SETUP_PORT" rm -rf "$SETUP_DATA" && mkdir -p "$SETUP_DATA" AUDIOSILO_BIND="127.0.0.1:$SETUP_PORT" AUDIOSILO_TLS_MODE=off \ + AUDIOSILO_WEB_DIR="$FRONTEND/dist" \ "$SERVER_BIN" --setup --data "$SETUP_DATA" \ > "$CACHE/setup.log" 2>&1 & SETUP_PID=$! @@ -213,7 +225,7 @@ if [ -z "$ADMIN_PASSWORD" ]; then echo "could not parse admin password from $CACHE/server.log"; exit 1 fi SETUP_URL="" -if [ "${SKIP_ADMIN:-0}" != "1" ]; then +if [ "$WANT_SETUP" = 1 ]; then SETUP_URL="$(grep -o "http://[^ ]*/setup#token=[^ ]*" "$CACHE/setup.log" | head -1 || true)" fi @@ -293,9 +305,17 @@ AS_BASE="http://127.0.0.1:$PORT/web/" ADMIN_PASSWORD="$ADMIN_PASSWORD" node capt if [ "${SKIP_ADMIN:-0}" != "1" ]; then echo "==> capturing admin console + public pages" AS_ORIGIN="http://127.0.0.1:$PORT" ADMIN_PASSWORD="$ADMIN_PASSWORD" \ - SETUP_URL="$SETUP_URL" \ MIRROR_ORIGIN="${MIRROR_PASSWORD:+http://127.0.0.1:$MIRROR_PORT}" \ MIRROR_PASSWORD="${MIRROR_PASSWORD:-}" 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 + else + echo " ! no setup URL in $CACHE/setup.log: the setup shots keep their previous images" + fi fi if [ "${SKIP_META:-0}" != "1" ]; then diff --git a/static/img/screenshots/server/setup-wizard-done.png b/static/img/screenshots/server/setup-wizard-done.png new file mode 100644 index 00000000..bc5bcd28 Binary files /dev/null and b/static/img/screenshots/server/setup-wizard-done.png differ diff --git a/static/img/screenshots/server/setup-wizard.png b/static/img/screenshots/server/setup-wizard.png index 82a71c58..683757b2 100644 Binary files a/static/img/screenshots/server/setup-wizard.png and b/static/img/screenshots/server/setup-wizard.png differ diff --git a/static/img/screenshots/setup/abs.png b/static/img/screenshots/setup/abs.png new file mode 100644 index 00000000..52c1b178 Binary files /dev/null and b/static/img/screenshots/setup/abs.png differ diff --git a/static/img/screenshots/setup/backups.png b/static/img/screenshots/setup/backups.png new file mode 100644 index 00000000..0b94af52 Binary files /dev/null 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 new file mode 100644 index 00000000..a6b1feaf Binary files /dev/null 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 new file mode 100644 index 00000000..7390e26f Binary files /dev/null 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 new file mode 100644 index 00000000..4de622a2 Binary files /dev/null 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 new file mode 100644 index 00000000..0bff5b13 Binary files /dev/null and b/static/img/screenshots/setup/metadata.png differ diff --git a/static/img/screenshots/setup/naming.png b/static/img/screenshots/setup/naming.png new file mode 100644 index 00000000..6a0e9b01 Binary files /dev/null and b/static/img/screenshots/setup/naming.png differ diff --git a/static/img/screenshots/setup/phone.png b/static/img/screenshots/setup/phone.png new file mode 100644 index 00000000..24e21ebe Binary files /dev/null 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 new file mode 100644 index 00000000..2cb69861 Binary files /dev/null and b/static/img/screenshots/setup/reach.png differ