diff --git a/cmd/prairie/main.go b/cmd/prairie/main.go index dfe0db8f7..fa0df7772 100644 --- a/cmd/prairie/main.go +++ b/cmd/prairie/main.go @@ -1180,7 +1180,13 @@ func main() { s.SetLiteraryWorkLinker(literaryWorkService) s.SetEbookEnrichmentQueue(ebooks.NewEnrichmentQueue(deps.DB)) deps.Scanner = s - deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, cfg.Playback.FFmpegPath, 10*time.Second) + probeEnsurer := scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, cfg.Playback.FFmpegPath, 10*time.Second) + // Probe repair and the copy-safety scan follow playback.ffmpeg_path + // without a restart; the scanner's own ffprobe path above still does not. + configWatcher.OnChange(func(_, updated *config.Config) { + probeEnsurer.SetFFmpegPath(updated.Playback.FFmpegPath) + }) + deps.ProbeEnsurer = probeEnsurer slog.Info("scanner initialized") } diff --git a/contracts/settings/v1/conformance.json b/contracts/settings/v1/conformance.json index 63fb19d7b..bcf2146e1 100644 --- a/contracts/settings/v1/conformance.json +++ b/contracts/settings/v1/conformance.json @@ -1,6 +1,6 @@ { "fixture_version": 1, - "manifest_revision": 7, + "manifest_revision": 8, "description": "Cross-platform conformance cases for settings resolution. Every case runs against the shipped manifest in this directory: definitions are referenced by key, never restated, so an expectation can only be satisfied by resolving the real contract. Each platform's resolver (Go in internal/settingsresolve, TypeScript in web/src/lib/settingsResolve.ts, Kotlin and Swift in the client repos) runs every case through a hand-written runner; a runner must fail on any fixture field it does not know, because schema drift in the fixture itself is drift. A case's constraint_bindings attach a constraint to a copy of a real definition so constraint semantics stay testable even while no shipped definition carries that constraint kind. In expected entries, constrained:true requires stored_value and constraint_kind to be present, and stored_value may be null to mean the authored value was JSON null.", "cases": [ { @@ -733,6 +733,149 @@ "source": "default" } ] + }, + { + "name": "card_overlays_enabled_profile_opt_out", + "description": "A profile that turned its own overlay badges off resolves false at profile scope; the server-wide default is not part of settings resolution.", + "keys": ["ui.card_overlays_enabled"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "ui.card_overlays_enabled", + "scope": "profile", + "profile_id": "p1", + "value": false + } + ], + "expected": [ + { + "key": "ui.card_overlays_enabled", + "value": false, + "source": "profile" + } + ] + }, + { + "name": "card_overlays_enabled_profile_opt_in", + "description": "A profile that turned its own overlay badges on resolves true at profile scope, which is what lets it override a server-wide default of off.", + "keys": ["ui.card_overlays_enabled"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "ui.card_overlays_enabled", + "scope": "profile", + "profile_id": "p1", + "value": true + } + ], + "expected": [ + { + "key": "ui.card_overlays_enabled", + "value": true, + "source": "profile" + } + ] + }, + { + "name": "card_overlays_absent_express_no_preference", + "description": "Nothing stored resolves both card-overlay keys to null with source \"default\", which is what lets the server-wide admin defaults apply.", + "keys": ["ui.card_overlays", "ui.card_overlays_enabled"], + "context": { "profile_id": "p1" }, + "stored": [], + "expected": [ + { + "key": "ui.card_overlays", + "value": null, + "source": "default" + }, + { + "key": "ui.card_overlays_enabled", + "value": null, + "source": "default" + } + ] + }, + { + "name": "card_quick_actions_profile_choice_wins", + "description": "A profile that picked favorites-only shortcuts resolves at profile scope instead of falling through to the server-wide default.", + "keys": ["ui.card_quick_actions"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "ui.card_quick_actions", + "scope": "profile", + "profile_id": "p1", + "value": "favorites" + } + ], + "expected": [ + { + "key": "ui.card_quick_actions", + "value": "favorites", + "source": "profile" + } + ] + }, + { + "name": "card_quick_actions_enabled_profile_opt_out", + "description": "A profile that turned its own quick actions off resolves false at profile scope; the server-wide default is not part of settings resolution.", + "keys": ["ui.card_quick_actions_enabled"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "ui.card_quick_actions_enabled", + "scope": "profile", + "profile_id": "p1", + "value": false + } + ], + "expected": [ + { + "key": "ui.card_quick_actions_enabled", + "value": false, + "source": "profile" + } + ] + }, + { + "name": "card_quick_actions_enabled_profile_opt_in", + "description": "A profile that turned its own quick actions on resolves true at profile scope, which is what lets it override a server-wide default of off.", + "keys": ["ui.card_quick_actions_enabled"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "ui.card_quick_actions_enabled", + "scope": "profile", + "profile_id": "p1", + "value": true + } + ], + "expected": [ + { + "key": "ui.card_quick_actions_enabled", + "value": true, + "source": "profile" + } + ] + }, + { + "name": "card_quick_actions_absent_express_no_preference", + "description": "Nothing stored resolves both quick-action keys to null with source \"default\", which is what lets the server-wide admin defaults apply.", + "keys": ["ui.card_quick_actions", "ui.card_quick_actions_enabled"], + "context": { "profile_id": "p1" }, + "stored": [], + "expected": [ + { + "key": "ui.card_quick_actions", + "value": null, + "source": "default" + }, + { + "key": "ui.card_quick_actions_enabled", + "value": null, + "source": "default" + } + ] } ] } diff --git a/contracts/settings/v1/manifest.json b/contracts/settings/v1/manifest.json index c79d66841..6509c8dcc 100644 --- a/contracts/settings/v1/manifest.json +++ b/contracts/settings/v1/manifest.json @@ -1,6 +1,6 @@ { "api_version": 1, - "revision": 7, + "revision": 8, "option_sets": { "playback_audio_languages": { "type": "language_tag", @@ -828,7 +828,60 @@ "category": "appearance", "label": "Poster badges", "description": "Which badges appear on poster cards, and where.", - "notes": "Registered from the legacy unprefixed key card_overlays, which reached the server only through the unknown-key extension bag — stored as an arbitrary string with no validation. null means the user has expressed no preference, which is what lets the server-wide admin default in the overlay-config endpoint apply; writing a resolved-but-unchosen value would silently pin them. The admin default and the enabled kill switch stay in server_settings and are not user settings." + "notes": "Registered from the legacy unprefixed key card_overlays, which reached the server only through the unknown-key extension bag — stored as an arbitrary string with no validation. null means the user has expressed no preference, which is what lets the server-wide admin default in the overlay-config endpoint apply; writing a resolved-but-unchosen value would silently pin them. The server-wide default configuration stays in server_settings and is not a user setting. This key describes which badges to show, not whether to show them — that is ui.card_overlays_enabled." + }, + { + "key": "ui.card_overlays_enabled", + "introduced_in": 8, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { "type": "boolean", "nullable": true }, + "default_value": null, + "platforms": ["web"], + "category": "appearance", + "label": "Card overlays enabled", + "description": "Show overlay badges on media cards.", + "recommended_control": "switch", + "notes": "null means the profile has not chosen whether overlay badges are enabled, so the server-wide default from the overlay-config endpoint applies. An explicit profile choice overrides the server-wide default in either direction." + }, + { + "key": "ui.card_quick_actions", + "introduced_in": 8, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "enum", + "nullable": true, + "values": [ + { "value": "both", "label": "Both" }, + { "value": "favorites", "label": "Favorites only" }, + { "value": "watched", "label": "Watch indicator only" } + ] + }, + "default_value": null, + "platforms": ["web"], + "category": "appearance", + "label": "Card quick actions", + "description": "Which favorite and watched shortcuts appear on media cards.", + "recommended_control": "select", + "notes": "null means the user has not chosen a mode, so the server-wide default from the overlay-config endpoint applies. The mode applies whenever quick actions are effectively enabled for the profile." + }, + { + "key": "ui.card_quick_actions_enabled", + "introduced_in": 8, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { "type": "boolean", "nullable": true }, + "default_value": null, + "platforms": ["web"], + "category": "appearance", + "label": "Card quick actions enabled", + "description": "Show the selected favorite and watched shortcuts on media cards.", + "recommended_control": "switch", + "notes": "null means the profile has not chosen whether quick actions are enabled, so the server-wide default from the overlay-config endpoint applies. An explicit profile choice overrides the server-wide default in either direction." }, { "key": "ui.next_up_mode", diff --git a/docs/admin-api.md b/docs/admin-api.md index f29fb8b46..0683b56c6 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -1,14 +1,91 @@ # Admin API +<<<<<<< HEAD Server-administration endpoints under `/api/v1/admin`. Every route here requires an authenticated account with the server-wide `admin` role — the same authorization as `/api/v1/admin/sessions` — and none of them are part of the client-facing contract that third-party apps build against. +======= +Server-administration endpoints under `/api/v1/admin`. Every `/api/v1/admin` +route requires an authenticated account with the server-wide `admin` role — the +same authorization as `/api/v1/admin/sessions` — and none of them are part of +the client-facing contract that third-party apps build against. A few +deliberately public reads outside `/api/v1/admin` (marked `public` in the route +tables) are documented beside the admin writes they pair with. +>>>>>>> cursor/sync-silo-server-d673 This document is new and covers only the routes listed below. The rest of the admin surface predates it and is currently documented by the code and by the design documents under `docs/design/`. +<<<<<<< HEAD +======= +## Branding assets + +Uploadable images white-label the server: the sidebar wordmark, the square +mark (collapsed sidebar and installed PWA), optional light-theme variants of +both, the browser favicon, and the login background. Each is stored in the public S3 bucket and referenced from a +`server_settings` row, so uploads return `503 unavailable` until +`s3.public_bucket` is configured. + +| Route | Auth | Purpose | +| --------------------------------------------- | ------ | -------------------------------------------------------------------- | +| `POST /api/v1/admin/branding/assets/{kind}` | admin | Upload (multipart, field name `file`). Replaces whatever is stored. | +| `DELETE /api/v1/admin/branding/assets/{kind}` | admin | Clear the asset. `204`, and clearing an unset asset is not an error. | +| `GET /api/v1/branding/assets/{kind}` | public | Serve the stored bytes. Content-addressed, so `immutable` cached. | +| `GET /api/v1/theme/branding` | public | Current branding, including each asset URL (omitted when unset). | + +Public reads are deliberately unauthenticated: branding has to apply on the +login page, before anyone has a session. + +`{kind}` is one of `wordmark`, `wordmark_light`, `mark`, `mark_light`, `favicon`, `login_bg` — the light variants follow their base kind's processing. Uploads are +processed per kind — the numbers below are the contract the admin UI quotes back +to the operator, and they live in `internal/branding/assets.go`: + +| Kind | Accepts | Max upload | Stored as | +| ---------- | ------------------- | ---------- | ------------------------------------------------------------------------------------------------------------- | +| `wordmark` | PNG, JPEG, WebP | 8 MB | WebP, aspect preserved, capped at 640px wide. Narrower art is not enlarged. | +| `mark` | PNG, JPEG, WebP | 8 MB | WebP, center-cropped to a square, then forced to exactly 512×512 (smaller art is upscaled). | +| `favicon` | PNG, WebP, ICO, SVG | 1 MB | Byte-for-byte as uploaded, so `.ico` and `.svg` keep working in browsers that will not render a WebP favicon. | +| `login_bg` | PNG, JPEG, WebP | 12 MB | WebP, aspect preserved, capped at 2560px wide. Clients display it cover-cropped. | + +There is one stored variant per kind, not a responsive set: the PWA manifest +advertises the single 512px mark at both 192×192 and 512×512, and native clients +read the same URLs as the web app. Recommend source art at or above the stored +size — anything larger is downscaled, anything smaller is either left small +(wordmark, login background) or upscaled (mark). + +Failure modes: `400 bad_request` for an unknown kind, a missing `file` field, or +a content type the kind does not accept; `413 too_large` past the cap; +`503 unavailable` when asset storage is not configured. + +Uploaded SVG favicons are admin-controlled but served from the app origin, so +every asset response carries `X-Content-Type-Options: nosniff` and a sandboxing +`Content-Security-Policy` — a directly-navigated SVG cannot run script in the +viewer's session. + +## Server status and restarts + +Some settings are only read at startup. Two routes carry that contract: + +| Route | Auth | Purpose | +|---|---|---| +| `GET /api/v1/admin/server/status` | admin | Process start time and pending-restart state. | +| `GET /api/v1/admin/settings/restart-keys` | admin | The compiled registry of setting keys that only take effect after a restart (`internal/config/restart_keys.go`). | + +`GET /api/v1/admin/server/status` response: + +| Field | Type | Meaning | +|---|---|---| +| `started_at` | RFC3339 string | When this process started. | +| `restart_required` | bool | A restart-required change was saved. Latches true for the life of the process; a real restart clears it by starting a new process. | +| `restart_required_at` | RFC3339 string | When the flag first latched. Omitted until then. | +| `restart_required_reason` | string | The reason of the **last** restart-required save only — later saves overwrite it. | +| `restart_required_reasons` | string[] | Every distinct reason since boot, first-seen order. Settings saves record one `setting:` entry per restart-required key, so a client can scope a pending restart to the subsystem it belongs to. | +| `restart_mark_count` | int | Increments on every restart-required save. Because the boolean latches, this counter is the only signal that a **new** requirement arrived — the admin UI re-arms its dismissed restart banner on it. | +| `restart_requested`, `restart_requested_at` | bool, RFC3339 string | An in-app restart was requested, and when. | + +>>>>>>> cursor/sync-silo-server-d673 ## `GET /api/v1/admin/stream-telemetry/parity` Returns the merged stream-telemetry view beside the two legacy live-session @@ -32,6 +109,7 @@ Always `200 OK`. "Nothing to compare" is expressed in the body rather than as an error status, because an empty report with a success status would read as agreement. +<<<<<<< HEAD | Field | Type | Meaning | |---|---|---| | `enabled` | bool | Stream telemetry is running in this process. | @@ -76,6 +154,52 @@ Each entry in `sources`: | `mismatches` | object[] | Per-session field disagreements, capped. | | `mismatches_truncated` | int | How many the cap dropped. | | `fields_absent` | object | Per field, sessions both sides know where one side carries no value. A gap in a projection, not a disagreement. | +======= +| Field | Type | Meaning | +| --------- | ------ | ------------------------------------------------------------------------------------ | +| `enabled` | bool | Stream telemetry is running in this process. | +| `reason` | string | Present when there is nothing to compare (telemetry disabled, or no view built yet). | +| `view` | object | State of the merged view the comparison was built from. | +| `sources` | array | One report per legacy projection. Empty when `enabled` is false. | + +`view`: + +| Field | Type | Meaning | +| ------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `available` | bool | A merged view exists. | +| `built_at` | RFC3339 string | When it was built. Omitted if never. | +| `age_ms`, `stale` | int, bool | Age of the cached view, and whether it exceeded the TTL. | +| `build_took_ms` | int | Cost of the last rebuild. | +| `refreshes`, `failures`, `last_error` | int, int, string | Cache counters since process start. | +| `complete` | bool | No publisher was stale, degraded or truncated. | +| `incomplete_reasons` | string[] | Why `complete` is false — e.g. `missing_publisher`, `publisher_truncated`, `decode_errors`, `truncated`. | +| `missing_publishers` | string[] | Publisher ids present in the roster but with no usable snapshot. | +| `clock_skew_suspected` | bool | A publisher stamped a time in the future. A clock running _behind_ is indistinguishable from a stalled publisher in one sample; compare `publishers` sequence across two reads to tell them apart. | +| `publishers` | string[] | `=`, where state is `fresh`, `degraded`, `stale` or `departed`. | +| `session_count`, `transfer_count` | int | Sizes of the merged view. | + +Each entry in `sources`: + +| Field | Type | Meaning | +| ----------- | -------- | -------------------------------------------- | +| `source` | string | `playback_sessions_sync` or `node_sessions`. | +| `available` | bool | The projection could be read. | +| `error` | string | Why it could not. | +| `notes` | string[] | Caveats that apply to this comparison. | +| `report` | object | The diff, when available. | + +`report`: + +| Field | Type | Meaning | +| --------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `telemetry_count`, `legacy_count`, `in_both` | int | Session counts on each side and their intersection. | +| `agrees` | bool | Same session set, and no field both sides express disagrees. Read `fields_absent` before treating this as clearance to cut over. | +| `telemetry_only`, `legacy_only` | string[] | Session ids present on one side only, capped. | +| `telemetry_only_truncated`, `legacy_only_truncated` | int | How many ids the cap dropped. | +| `mismatches` | object[] | Per-session field disagreements, capped. | +| `mismatches_truncated` | int | How many the cap dropped. | +| `fields_absent` | object | Per field, sessions both sides know where one side carries no value. A gap in a projection, not a disagreement. | +>>>>>>> cursor/sync-silo-server-d673 A single report samples three independently updated stores, so one-sided differences are normal and are not on their own evidence of a defect. Repeated diff --git a/docs/architecture/admin-settings-ux.md b/docs/architecture/admin-settings-ux.md new file mode 100644 index 000000000..947628cc7 --- /dev/null +++ b/docs/architecture/admin-settings-ux.md @@ -0,0 +1,159 @@ +# Admin settings UX + +Admin settings are organized by admin intent ("I want subtitles to download +automatically"), not by subsystem. `/admin/settings` is the **Overview**: +server health across the top and one live card per settings group. Twelve +standalone pages hang off it: General, Storage & Database, Appearance, +Security & Access, Library & Metadata, Playback, Downloads, Subtitles & +Metadata, Watch Providers, AI Services, Notifications, and Compatibility. The +global admin sidebar has one Settings destination; the Overview owns the +settings information architecture. Old `?tab=` URLs and retired page ids from +earlier layouts (including `integrations`, now split into Subtitles & Metadata, +Watch Providers, and AI Services) redirect to the page that absorbed them +rather than 404ing. +`⌘K` (`AdminSectionCommandDialog`) is mounted in `AdminLayout` so search works +from every admin page, not just the Dashboard. + +## Visual system + +One page is on screen at a time, and each thing on screen carries one signal. +The admin settings detail view deliberately mirrors the user settings page +(`SettingsLayout`): one `surface-panel-lg` shell with a `SideNavItem` rail on +the left and the page content on the right, so the two settings surfaces read +as the same product. The Overview stays the category directory: it explains +each group's scope, and every category has its own `/admin/settings/:page` +route. The rail (`SettingsPageRail`, rendered by the settings shell) lists +every settings page with the open one marked; an All settings link above the +shell leads back to the directory. The rail is desktop-only — on smaller +screens the Overview is the directory, exactly as on the user side. The +Overview shows a health tile only for a tile in `warn` or `off`. The +**Setup & health** section explains that it holds recommendations and +configuration problems; an empty checklist reads "No action needed" and +names the conditions that will appear there. Below it is one card per +settings group. Each card explains the group's scope and names the sections +inside it. Live state stays in the health area instead of reducing a +multi-provider group to one misleading summary. + +A category page opens with `SettingsPageHeader`: the title, and page actions +if it has any. No breadcrumb, no lede, no status strip. Below it, settings +are hairline-ruled rows inside `FieldGroup`s — thin wrappers over the shared +`SettingsGroup` panel the user settings pages use — one panel per group, +never per field. The Advanced tier stays inline as one disclosure row per +group. A row can carry its `server_settings` key as a mono caption under the +label (`SettingField`'s `settingKey`) so an admin can match the UI to the +API and environment overrides, and a violet dot marks unsaved edits on the +row and on the group heading (`dirty`, driven from `form.isDirty`). A +description under a field label is the exception, not the rule: one short +sentence, and only when the label alone is ambiguous. Units live beside the +control (`SettingField`'s `unit`), not in the label. When every field in a +group needs a restart, the group says so once (`FieldGroup restartAll`) and +the fields inside drop their chips. Provider credentials are `ProviderTile`s +that expand in place to Test before saving; their border is neutral in every +state and the state is a dot plus a word in the header. Provider setup lives on +a provider page (Subtitles & Metadata, Watch Providers), not on the page that +owns the feature: Library & Metadata decides *whether* Silo looks for intro and +credits markers, while *which provider answers, in what order, and on what +terms* is a tile beside the subtitle and metadata providers, with a cross-link +each way. A tile only reads "Connected" when the provider could actually serve +a request — its configuration saved and the provider switched on — so an +installed plugin whose API key was never entered reads "Needs setup". Staged edits raise +one floating save pill (`SaveBar`) and arm the shell's unsaved-changes prompt; +the restart prompt is a single `RestartBanner` (`web/src/components/admin/`) +rendered by the admin shell (`AdminLayout`), never per page. A restart is owed +by the server, not by the page that asked for it, so the banner sits in the +flow at the top of the content column on *every* admin page — dashboard, +users, tasks, settings — and follows the admin around until they restart or +dismiss it. + +## Three tiers, and how to pick one for a new setting + +Every admin setting is one of: + +- **Essential** — shown by default, no disclosure needed. Target at most ~8 + essential controls per page above the fold. A setting is Essential only if a + household admin on a single-node install would plausibly need it without + being told to look for it (on/off toggles for a whole feature, the handful + of values that make the feature usable at all). +- **Advanced** — correct but not essential; collapsed by default behind one + `AdvancedSection` disclosure per page (or per `FieldGroup` on a dense page). + Open state persists in `localStorage` and auto-expands when a dirty or + invalid field lives inside it. Tuning knobs, alternate backends, + and anything whose default is good enough that most admins never touch it + belong here. +- **Hidden** — no UI at all, on any page. The setting is still a normal + `server_settings` row: readable and writable through the admin settings API + and environment configuration exactly as before this reorganization. Use + Hidden for legacy key families kept for compatibility, settings that only + make sense with expert knowledge of the codebase, or values better derived + automatically (e.g. from node pool capacity) than hand-set. + +The tier is a UI-only decision. It must never change a key's validation, +default resolution, or API visibility — moving a setting to Hidden is +reversible by adding UI back, not by a data migration. + +## Shared primitives + +Reuse these instead of adding a bespoke variant per page: + +- `SettingField` / `FieldGroup` / `SaveBar` (`web/src/pages/admin-settings/`) + and `useSettingsForm` (`web/src/hooks/`) — the one save model. Every page + batches edits and commits them through one `SaveBar` with Discard; provider + credentials are the only exception, and only because they need + Test-before-commit, which is `ProviderTile` rather than a bespoke card per + provider. +- `UnsavedChangesGuard` (`web/src/components/`) plus `useReportUnsavedChanges` + (`web/src/hooks/useUnsavedChanges.ts`) — the one unsaved-edits prompt. A form + only reports that it is dirty; the settings shell mounts the guard once and + blocks router navigation (rail, back link, admin sidebar, browser back) with + a confirmation. `useSettingsForm` keeps a `beforeunload` listener for tab + close and reload, which the router never sees. Blocking is `useBlocker`, + which is why `App.tsx` mounts a data router (`createBrowserRouter` + + `RouterProvider`) — a page must never grow its own prompt, its own blocker, + or its own draft store. +- `SettingsPageHeader` (`web/src/components/settings/`) — the one way a + section names itself. Live state belongs on the Overview, not repeated as a + strip on every page. +- `SettingsPageRail` (`web/src/components/settings/`) — the one sibling nav, + rendered once by the settings shell from `ADMIN_SETTINGS_NAV` using the + shared `SideNavSection`/`SideNavItem` primitives. Pages never render their + own nav or add entries directly to the rail. +- `SettingsGroup` (`web/src/components/settings/`) — the one settings panel, + shared with the user settings pages; admin pages reach it through the + `FieldGroup` wrapper, which layers on the restart-all line, the + unsaved-edits dot, and the restart context. +- `AdvancedSection` — the one collapsible-disclosure primitive for the + Advanced tier. Do not add another `
`, another bespoke collapsible + component, or a per-page expand/collapse toggle. +- `SecretField` — the one credential control: an always-editable password + input whose masked placeholder stands in for the saved value. Typing stages + a replacement; emptying the input keeps the saved secret, so no ordinary save + erases one by accident. Clearing is always a deliberate act, and every + surface has exactly one way to do it: either a page-level action (Disconnect, + Clear credentials) or, where the page has none, the field's own opt-in + `onClear`/`cleared` affordance, which stages the empty write for the save bar + and can be taken back with "Keep saved value" or Discard. +- `LimitField` — the one "Unlimited" checkbox pattern, replacing "0 = unlimited" + hint text conventions. +- A restart badge on `SettingField` itself, sourced from + `config.RestartRequired` (`internal/config/restart_keys.go`), not hand-copied + into hint text or inferred by a page-local heuristic. `RestartRequired` is + the single source of truth for which keys need a process restart to take + effect; a new field's badge must read that function (directly, or via a + manifest/meta endpoint built on top of it) rather than duplicating its + judgment. + +## Deferred out of this reorganization + +These were identified during the review but deliberately left for later work, +not folded into this pass: + +- Key renames (e.g. un-namespaced `allow_4k_transcode`, + `enable_transcode_throttle`, `transcode_throttle_seconds` moving under + `playback.*`). +- Introducing `server.public_url` as one canonical public URL that + `jellyfin_compat.public_url` and friends would derive from. +- Deleting the legacy `s3.operational_*` rows that a past migration copied and + never removed. + +Each still applies via its existing key and behavior; only the UI +reorganization and tiering in this document shipped now. diff --git a/docs/architecture/email.md b/docs/architecture/email.md index 16fc9dd3c..b0d655255 100644 --- a/docs/architecture/email.md +++ b/docs/architecture/email.md @@ -38,7 +38,7 @@ low): | `email.from_address` | — | required | | `email.from_name` | `Prairie` | | -Admin UI: Admin Settings → Connections → Email, including a synchronous test +Admin UI: Admin Settings → Notifications → Email, including a synchronous test send (`POST /api/v1/admin/email/test`). ## Adding a consumer diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index 4c0d483e0..7e385fd95 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -140,6 +140,13 @@ type AdminHandler struct { OnServerSettingUpdated func(ctx context.Context, key, value string) RestartStatus *ServerRestartStatusTracker CatalogSearchStatus catalog.CatalogSearchStatusProvider + // PublicStorageConfigured reports whether the public object-storage client + // is active in this process — the same condition that gates branding asset + // uploads (branding.Service.HasStorage) and the metadata image cacher, both + // of which are only wired when the public S3 client exists. A nil func + // means "not configured". See publicBucketConfigured for the full rule, + // which also accepts a bucket that is saved but not live yet. + PublicStorageConfigured func() bool } // NewAdminHandler creates a new AdminHandler backed by the given @@ -1619,6 +1626,22 @@ func (h *AdminHandler) HandleGetEffectiveSettings(w http.ResponseWriter, r *http writeJSON(w, http.StatusOK, effective) } +type restartKeysResponse struct { + Keys []string `json:"keys"` + Prefixes []string `json:"prefixes"` +} + +// HandleGetRestartKeys handles GET /admin/settings/restart-keys. The registry +// is compiled into the binary (internal/config), so the response only changes +// across deploys; the admin UI caches it and uses it to badge the fields whose +// saved value waits on a restart. +func (h *AdminHandler) HandleGetRestartKeys(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, restartKeysResponse{ + Keys: config.RestartRequiredKeys(), + Prefixes: config.RestartRequiredPrefixes(), + }) +} + type sensitiveStatusResponse struct { Configured []string `json:"configured"` ManagedByEnv []string `json:"managed_by_env,omitempty"` @@ -1675,8 +1698,10 @@ func (h *AdminHandler) HandleGetSensitiveStatus(w http.ResponseWriter, r *http.R type adminSettingResponse struct { Key string `json:"key"` Value string `json:"value"` - // RestartRequired reports whether the saved value only takes effect - // after a server restart (set on update responses only). + // RestartRequired reports whether the value only takes effect after a + // server restart. It is populated from the compiled restart-key registry + // on read responses as well as on updates, so the admin UI never has to + // hand-copy the list into hint text. RestartRequired bool `json:"restart_required,omitempty"` } @@ -2196,7 +2221,11 @@ func (h *AdminHandler) HandleGetSetting(w http.ResponseWriter, r *http.Request) } if value, ok := h.BootstrapSensitiveValues[key]; ok && value != "" { - writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: value}) + writeJSON(w, http.StatusOK, adminSettingResponse{ + Key: key, + Value: value, + RestartRequired: config.RestartRequired(key), + }) return } @@ -2210,7 +2239,11 @@ func (h *AdminHandler) HandleGetSetting(w http.ResponseWriter, r *http.Request) return } - writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: value}) + writeJSON(w, http.StatusOK, adminSettingResponse{ + Key: key, + Value: value, + RestartRequired: config.RestartRequired(key), + }) } type updateSettingRequest struct { @@ -2227,7 +2260,107 @@ type updateSettingsResponse struct { RestartRequiredKeys []string `json:"restart_required_keys,omitempty"` } -func (h *AdminHandler) normalizeBatchSetting(ctx context.Context, key, value string) (string, string, error) { +// settingMetadataCacheImages copies provider artwork into the public bucket, so +// it cannot be turned on without one. settingPublicBucketLegacy is the +// pre-rename alias config.db_loader still falls back to. +const ( + settingMetadataCacheImages = "metadata.cache_images" + settingPublicBucket = "s3.public_bucket" + settingPublicBucketLegacy = "s3.operational_bucket" +) + +// errCodeStorageUnavailable is the API error code for a setting that needs +// object storage this deployment has not configured. +const errCodeStorageUnavailable = "storage_unavailable" + +// errPublicStorageUnavailable is returned when a write would leave +// metadata.cache_images enabled with no public bucket anywhere: the image cacher +// is wired off the public S3 client, so caching could never start. +var errPublicStorageUnavailable = errors.New( + "S3 image caching requires a configured public storage bucket: metadata.cache_images cannot be " + + "enabled while s3.public_bucket is empty (Infrastructure \u2192 Public storage)") + +// publicBucketConfigured reports whether a public object-storage bucket exists +// from the server's point of view. A bucket that is only saved counts: an admin +// editing an inactive-but-saved deployment is one restart away. Only the live +// client proves caching starts immediately, so the UI still badges the pending +// restart — but the API must not block a legitimate save. +// +// This is the single-key endpoint's view: it writes one setting against +// whatever is already stored. The batch endpoint uses +// prospectivePublicBucketConfigured instead, because a bucket written or +// cleared by the same request has not reached the store yet. +func (h *AdminHandler) publicBucketConfigured(ctx context.Context) bool { + if h == nil { + return false + } + if h.PublicStorageConfigured != nil && h.PublicStorageConfigured() { + return true + } + for _, key := range []string{settingPublicBucket, settingPublicBucketLegacy} { + if h.BootstrapSensitiveConfigured[key] && + strings.TrimSpace(h.BootstrapSensitiveValues[key]) != "" { + return true + } + if h.SettingsRepo == nil { + continue + } + if stored, err := h.SettingsRepo.Get(ctx, key); err == nil && strings.TrimSpace(stored) != "" { + return true + } + } + return false +} + +// prospectivePublicBucketConfigured reports whether the settings a batch is +// about to persist still describe a public bucket. effective is the stored +// state overlaid with the batch and the environment and run through +// config.EffectiveAdminSettings, so the legacy s3.operational_bucket fallback +// LoadFromDB applies is already folded into settingPublicBucket; changed is the +// batch itself. +// +// A bucket key the batch does not mention leaves the live public client as +// evidence, because the bucket may come from a source the settings store cannot +// see. An explicitly empty bucket in the batch is a clear, not an absence: the +// live client only reflects what this process booted with, so it cannot vouch +// for storage the saved settings no longer describe. +func (h *AdminHandler) prospectivePublicBucketConfigured(effective, changed map[string]string) bool { + if h == nil { + return false + } + if strings.TrimSpace(effective[settingPublicBucket]) != "" { + return true + } + for _, key := range []string{settingPublicBucket, settingPublicBucketLegacy} { + if _, cleared := changed[key]; cleared { + return false + } + } + return h.PublicStorageConfigured != nil && h.PublicStorageConfigured() +} + +// validateProspectiveImageCaching rejects a batch whose final state leaves image +// caching enabled with nowhere to write. Both directions matter: enabling +// caching while clearing the bucket in the same request, and clearing the bucket +// while stored settings already have caching on. Either way the image cacher +// cannot start after the next restart. +func (h *AdminHandler) validateProspectiveImageCaching(effective, changed map[string]string) error { + // ParseBool matches config.LoadFromDB, which reads the stored value the same + // way; anything it rejects is not a deployment running with caching on. + enabled, _ := strconv.ParseBool(strings.TrimSpace(effective[settingMetadataCacheImages])) + if !enabled { + return nil + } + if h.prospectivePublicBucketConfigured(effective, changed) { + return nil + } + return errPublicStorageUnavailable +} + +func (h *AdminHandler) normalizeBatchSetting( + ctx context.Context, + key, value string, +) (string, string, error) { if strings.HasPrefix(key, "ratelimit.") { return "", "bad_request", fmt.Errorf("%s is managed by /admin/rate-limits/config", key) } @@ -2251,7 +2384,7 @@ func (h *AdminHandler) normalizeBatchSetting(ctx context.Context, key, value str case diagnostics.KeyUploadsEnabled: if normalized == "true" { if err = h.validateDiagnosticsUploadsEnabled(ctx); err != nil { - return "", "storage_unavailable", err + return "", errCodeStorageUnavailable, err } } case diagnostics.KeyMaxBundleBytes, @@ -2494,6 +2627,7 @@ func (h *AdminHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Reque after map[string]string effectiveChanges map[string]bool validationErr error + validationCode string ) err := updateServerSettingsAtomically(r.Context(), h.SettingsRepo, func(stored map[string]string) (map[string]string, error) { @@ -2502,13 +2636,22 @@ func (h *AdminHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Reque prospective[key] = value } activeProspective := h.activeAdminSettings(prospective) + before := h.effectiveAdminSettings(stored) + after = h.effectiveAdminSettings(prospective) + // Cross-field checks run against the complete prospective state, so a + // value the batch clears is gone even when the store still has it and + // the current process is still running on it. + if err := h.validateProspectiveImageCaching(after, normalized); err != nil { + validationErr = err + validationCode = errCodeStorageUnavailable + return nil, err + } validationSnapshot := adminSettingsValidationSnapshot(activeProspective, normalized) if err := validateProspectiveAdminSettings(validationSnapshot, h.RedisBootstrapAvailable); err != nil { validationErr = err + validationCode = "invalid_settings" return nil, err } - before := h.effectiveAdminSettings(stored) - after = h.effectiveAdminSettings(prospective) writes := make(map[string]string, len(normalized)) effectiveChanges = make(map[string]bool, len(normalized)) for key, value := range normalized { @@ -2523,7 +2666,7 @@ func (h *AdminHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Reque return writes, nil }) if validationErr != nil { - writeError(w, http.StatusBadRequest, "invalid_settings", validationErr.Error()) + writeError(w, http.StatusBadRequest, validationCode, validationErr.Error()) return } if err != nil { @@ -2551,8 +2694,11 @@ func (h *AdminHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Reque restartKeys = append(restartKeys, key) } } - if len(restartKeys) > 0 { - h.markServerRestartRequired("server_settings") + // Per-key reasons ("setting:") so the admin UI can scope a pending + // restart to the subsystem the key belongs to instead of warning on every + // tile for any settings save. + for _, restartKey := range restartKeys { + h.markServerRestartRequired("setting:" + restartKey) } writeJSON(w, http.StatusOK, updateSettingsResponse{ Values: responseValues, @@ -2648,6 +2794,11 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques return } req.Value = strconv.FormatBool(enabled) + case settingMetadataCacheImages: + if req.Value == "true" && !h.publicBucketConfigured(r.Context()) { + writeError(w, http.StatusBadRequest, errCodeStorageUnavailable, errPublicStorageUnavailable.Error()) + return + } case diagnostics.KeyUploadsEnabled: enabled, err := strconv.ParseBool(strings.TrimSpace(req.Value)) if err != nil { @@ -2657,7 +2808,7 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques req.Value = strconv.FormatBool(enabled) if enabled { if err := h.validateDiagnosticsUploadsEnabled(r.Context()); err != nil { - writeError(w, http.StatusBadRequest, "storage_unavailable", err.Error()) + writeError(w, http.StatusBadRequest, errCodeStorageUnavailable, err.Error()) return } } @@ -2830,6 +2981,7 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques after map[string]string effectiveChanged bool validationErr error + validationCode string ) err := updateServerSettingsAtomically(r.Context(), h.SettingsRepo, func(stored map[string]string) (map[string]string, error) { @@ -2838,14 +2990,28 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques // This legacy route can only change one key, so enforcing every // cross-field invariant would make paired settings impossible to // establish or clear one write at a time. Per-key validation above - // remains strict; Redis transport is the one durable prerequisite - // that may not be broken by a single-key write. + // remains strict; the durable prerequisites are the exception — a + // single-key write may not break them. if key == "redis.url" { if err := config.ValidateRedisRateLimitTransport( h.activeAdminSettings(prospective), h.RedisBootstrapAvailable, ); err != nil { validationErr = err + validationCode = "invalid_settings" + return nil, err + } + } + // Image caching's bucket is the other durable prerequisite: clearing + // it here while metadata.cache_images is stored on would leave the + // cacher unable to start after restart. Disable caching first. + if key == settingPublicBucket || key == settingPublicBucketLegacy { + if err := h.validateProspectiveImageCaching( + h.effectiveAdminSettings(prospective), + map[string]string{key: req.Value}, + ); err != nil { + validationErr = err + validationCode = errCodeStorageUnavailable return nil, err } } @@ -2859,7 +3025,7 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques return nil, nil }) if validationErr != nil { - writeError(w, http.StatusBadRequest, "invalid_settings", validationErr.Error()) + writeError(w, http.StatusBadRequest, validationCode, validationErr.Error()) return } if err != nil { @@ -2877,7 +3043,7 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques } restartRequired := effectiveChanged && config.RestartRequired(key) if restartRequired { - h.markServerRestartRequired("server_settings") + h.markServerRestartRequired("setting:" + key) } if sensitiveSettingKeys[key] { writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, RestartRequired: restartRequired}) diff --git a/internal/api/handlers/admin_cache_images_setting_test.go b/internal/api/handlers/admin_cache_images_setting_test.go new file mode 100644 index 000000000..2d9f3eab0 --- /dev/null +++ b/internal/api/handlers/admin_cache_images_setting_test.go @@ -0,0 +1,319 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" +) + +// metadata.cache_images copies provider artwork into the public bucket, so both +// write paths reject enabling it when no public bucket exists anywhere. A saved +// but not-yet-active bucket counts: the setup wizard configures the bucket and +// enables caching in one batch, and the UI badges the pending restart. + +func cacheImagesHandler(settings *fakeServerSettingsStore, storage bool) *AdminHandler { + h := &AdminHandler{SettingsRepo: settings} + if storage { + h.PublicStorageConfigured = func() bool { return true } + } + return h +} + +func updateCacheImagesBatch(h *AdminHandler, values string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.HandleUpdateSettings(rec, httptest.NewRequest( + http.MethodPut, + "/admin/settings", + strings.NewReader(`{"values":{`+values+`}}`), + )) + return rec +} + +func updateSingleSetting(h *AdminHandler, key, value string) *httptest.ResponseRecorder { + router := chi.NewRouter() + router.Put("/admin/settings/{key}", h.HandleUpdateSetting) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest( + http.MethodPut, + "/admin/settings/"+key, + strings.NewReader(`{"value":"`+value+`"}`), + )) + return rec +} + +func updateCacheImagesSingle(h *AdminHandler, value string) *httptest.ResponseRecorder { + return updateSingleSetting(h, "metadata.cache_images", value) +} + +func assertStorageUnavailable(t *testing.T, rec *httptest.ResponseRecorder) { + t.Helper() + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + var body errorResponse + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.Error != "storage_unavailable" { + t.Fatalf("error code = %q, want storage_unavailable; body=%#v", body.Error, body) + } + if !strings.Contains(body.Message, "S3 image caching requires a configured public storage bucket") { + t.Fatalf("error message = %q", body.Message) + } +} + +func TestCacheImagesEnableRequiresPublicBucket(t *testing.T) { + const cacheImagesTrue = `"metadata.cache_images":"true"` + + t.Run("batch with active storage", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{}} + rec := updateCacheImagesBatch(cacheImagesHandler(settings, true), cacheImagesTrue) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values["metadata.cache_images"] != "true" { + t.Fatalf("stored values = %#v", settings.values) + } + }) + + t.Run("batch with saved bucket but inactive store", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{"s3.public_bucket": "silo-public"}} + rec := updateCacheImagesBatch(cacheImagesHandler(settings, false), cacheImagesTrue) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values["metadata.cache_images"] != "true" { + t.Fatalf("stored values = %#v", settings.values) + } + }) + + // The setup wizard writes both in one request, before any restart. + t.Run("batch configuring the bucket in the same request", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{}} + rec := updateCacheImagesBatch(cacheImagesHandler(settings, false), + cacheImagesTrue+`,"s3.public_endpoint":"https://s3.example.com","s3.public_bucket":"silo-public"`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values["metadata.cache_images"] != "true" || + settings.values["s3.public_bucket"] != "silo-public" { + t.Fatalf("stored values = %#v", settings.values) + } + }) + + // The batch is validated inside the settings transaction, so it reads the + // stored values but must not write any of them. + t.Run("batch with no bucket anywhere", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{}} + rec := updateCacheImagesBatch(cacheImagesHandler(settings, false), cacheImagesTrue) + assertStorageUnavailable(t, rec) + if settings.setManyCalls != 0 || settings.setCalls != 0 { + t.Fatalf("write attempted: setMany=%d set=%d", settings.setManyCalls, settings.setCalls) + } + if _, stored := settings.values["metadata.cache_images"]; stored { + t.Fatalf("stored values = %#v", settings.values) + } + }) + + // Clearing the bucket in the same batch must not count as configuring one. + t.Run("batch clearing the bucket while enabling", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{}} + rec := updateCacheImagesBatch(cacheImagesHandler(settings, false), + cacheImagesTrue+`,"s3.public_bucket":""`) + assertStorageUnavailable(t, rec) + }) + + // The batch persists atomically, so a bucket it clears is gone even though + // the store — and the process still running on it — have one right now. + t.Run("batch clearing a stored bucket while enabling", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{"s3.public_bucket": "silo-public"}} + rec := updateCacheImagesBatch(cacheImagesHandler(settings, true), + cacheImagesTrue+`,"s3.public_bucket":""`) + assertStorageUnavailable(t, rec) + if settings.setManyCalls != 0 || settings.setCalls != 0 { + t.Fatalf("write attempted: setMany=%d set=%d", settings.setManyCalls, settings.setCalls) + } + if settings.values["s3.public_bucket"] != "silo-public" { + t.Fatalf("stored values = %#v", settings.values) + } + }) + + // The dual: caching is already on and the batch never resubmits it, so only + // the complete prospective state catches the storage disappearing. + t.Run("batch clearing the bucket while caching stays on", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{ + "metadata.cache_images": "true", + "s3.public_bucket": "silo-public", + }} + rec := updateCacheImagesBatch(cacheImagesHandler(settings, true), `"s3.public_bucket":""`) + assertStorageUnavailable(t, rec) + if settings.values["s3.public_bucket"] != "silo-public" { + t.Fatalf("stored values = %#v", settings.values) + } + }) + + t.Run("batch clearing the public endpoint and bucket while caching stays on", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{ + "metadata.cache_images": "true", + "s3.public_endpoint": "https://s3.example.com", + "s3.public_bucket": "silo-public", + }} + rec := updateCacheImagesBatch(cacheImagesHandler(settings, true), + `"s3.public_endpoint":"","s3.public_bucket":""`) + assertStorageUnavailable(t, rec) + if settings.values["s3.public_bucket"] != "silo-public" { + t.Fatalf("stored values = %#v", settings.values) + } + }) + + // Turning caching off in the same batch is the documented way out. + t.Run("batch disabling caching while clearing the bucket", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{ + "metadata.cache_images": "true", + "s3.public_endpoint": "https://s3.example.com", + "s3.public_bucket": "silo-public", + }} + rec := updateCacheImagesBatch(cacheImagesHandler(settings, true), + `"metadata.cache_images":"false","s3.public_endpoint":"","s3.public_bucket":""`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values["metadata.cache_images"] != "false" || + settings.values["s3.public_bucket"] != "" { + t.Fatalf("stored values = %#v", settings.values) + } + }) + + // LoadFromDB falls back to the pre-rename bucket key, so clearing only the + // canonical one still leaves the cacher a bucket. + t.Run("batch clearing the bucket while the legacy bucket remains", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{ + "metadata.cache_images": "true", + "s3.public_endpoint": "https://s3.example.com", + "s3.public_bucket": "silo-public", + "s3.operational_bucket": "silo-legacy", + }} + rec := updateCacheImagesBatch(cacheImagesHandler(settings, true), `"s3.public_bucket":""`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values["s3.public_bucket"] != "" { + t.Fatalf("stored values = %#v", settings.values) + } + }) + + t.Run("batch disable with no bucket", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{"metadata.cache_images": "true"}} + rec := updateCacheImagesBatch(cacheImagesHandler(settings, false), `"metadata.cache_images":"false"`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values["metadata.cache_images"] != "false" { + t.Fatalf("stored values = %#v", settings.values) + } + }) + + t.Run("single with active storage", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{}} + rec := updateCacheImagesSingle(cacheImagesHandler(settings, true), "true") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values["metadata.cache_images"] != "true" { + t.Fatalf("stored values = %#v", settings.values) + } + }) + + t.Run("single with saved bucket but inactive store", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{"s3.public_bucket": "silo-public"}} + rec := updateCacheImagesSingle(cacheImagesHandler(settings, false), "true") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values["metadata.cache_images"] != "true" { + t.Fatalf("stored values = %#v", settings.values) + } + }) + + t.Run("single with an environment-supplied bucket", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{}} + h := cacheImagesHandler(settings, false) + h.BootstrapSensitiveConfigured = map[string]bool{"s3.public_bucket": true} + h.BootstrapSensitiveValues = map[string]string{"s3.public_bucket": "silo-public"} + rec := updateCacheImagesSingle(h, "true") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values["metadata.cache_images"] != "true" { + t.Fatalf("stored values = %#v", settings.values) + } + }) + + t.Run("single with no bucket anywhere", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{}} + rec := updateCacheImagesSingle(cacheImagesHandler(settings, false), "true") + assertStorageUnavailable(t, rec) + if settings.setCalls != 0 { + t.Fatalf("Set calls = %d, want 0", settings.setCalls) + } + if _, stored := settings.values["metadata.cache_images"]; stored { + t.Fatalf("stored values = %#v", settings.values) + } + }) + + t.Run("single disable with no bucket", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{"metadata.cache_images": "true"}} + rec := updateCacheImagesSingle(cacheImagesHandler(settings, false), "false") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values["metadata.cache_images"] != "false" { + t.Fatalf("stored values = %#v", settings.values) + } + }) + + // The legacy single-key route must hold the same line as the batch: + // clearing the bucket while caching is stored on strands the cacher. + t.Run("single bucket clear while caching is on", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{ + "metadata.cache_images": "true", + "s3.public_bucket": "silo-public", + }} + rec := updateSingleSetting(cacheImagesHandler(settings, true), "s3.public_bucket", "") + assertStorageUnavailable(t, rec) + if settings.values["s3.public_bucket"] != "silo-public" { + t.Fatalf("stored values = %#v", settings.values) + } + }) + + t.Run("single bucket clear while caching is off", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{ + "s3.public_bucket": "silo-public", + }} + rec := updateSingleSetting(cacheImagesHandler(settings, false), "s3.public_bucket", "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values["s3.public_bucket"] != "" { + t.Fatalf("stored values = %#v", settings.values) + } + }) + + t.Run("single bucket change to a new value while caching is on", func(t *testing.T) { + settings := &fakeServerSettingsStore{values: map[string]string{ + "metadata.cache_images": "true", + "s3.public_bucket": "silo-public", + }} + rec := updateSingleSetting(cacheImagesHandler(settings, false), "s3.public_bucket", "silo-art") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values["s3.public_bucket"] != "silo-art" { + t.Fatalf("stored values = %#v", settings.values) + } + }) +} diff --git a/internal/api/handlers/admin_restart_keys_test.go b/internal/api/handlers/admin_restart_keys_test.go new file mode 100644 index 000000000..d24e25297 --- /dev/null +++ b/internal/api/handlers/admin_restart_keys_test.go @@ -0,0 +1,61 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "slices" + "sort" + "testing" + + "github.com/prairie-server/prairie-server/internal/config" +) + +// The admin UI badges restart-required fields from this endpoint, so it has to +// report the compiled registry verbatim — both exact keys and whole namespaces. +func TestHandleGetRestartKeys(t *testing.T) { + handler := &AdminHandler{} + rec := httptest.NewRecorder() + + handler.HandleGetRestartKeys(rec, httptest.NewRequest(http.MethodGet, "/admin/settings/restart-keys", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var body struct { + Keys []string `json:"keys"` + Prefixes []string `json:"prefixes"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + + if !slices.Equal(body.Keys, config.RestartRequiredKeys()) { + t.Errorf("keys = %v, want %v", body.Keys, config.RestartRequiredKeys()) + } + if !slices.Equal(body.Prefixes, config.RestartRequiredPrefixes()) { + t.Errorf("prefixes = %v, want %v", body.Prefixes, config.RestartRequiredPrefixes()) + } + if !sort.StringsAreSorted(body.Keys) { + t.Errorf("keys are not sorted: %v", body.Keys) + } + + // Spot-check both matching modes so a registry refactor that drops one of + // them fails here rather than silently un-badging the UI. + if !slices.Contains(body.Keys, "auth.jwt_secret") { + t.Errorf("keys missing auth.jwt_secret: %v", body.Keys) + } + if !slices.Contains(body.Prefixes, "database.") { + t.Errorf("prefixes missing database.: %v", body.Prefixes) + } + for _, key := range body.Keys { + if !config.RestartRequired(key) { + t.Errorf("reported key %q is not restart-required", key) + } + } + for _, prefix := range body.Prefixes { + if !config.RestartRequired(prefix + "example") { + t.Errorf("reported prefix %q does not mark its namespace restart-required", prefix) + } + } +} diff --git a/internal/api/handlers/admin_server_status.go b/internal/api/handlers/admin_server_status.go index ed18ffc8a..30e085d3d 100644 --- a/internal/api/handlers/admin_server_status.go +++ b/internal/api/handlers/admin_server_status.go @@ -2,6 +2,7 @@ package handlers import ( "net/http" + "slices" "time" "github.com/prairie-server/prairie-server/internal/jellycompat" @@ -12,14 +13,32 @@ type adminServerStatusResponse struct { RestartRequired bool `json:"restart_required"` RestartRequiredAt *time.Time `json:"restart_required_at,omitempty"` RestartRequiredReason string `json:"restart_required_reason,omitempty"` - RestartRequested bool `json:"restart_requested"` - RestartRequestedAt *time.Time `json:"restart_requested_at,omitempty"` + // RestartRequiredReasons accumulates every distinct reason marked since + // boot ("setting:" entries for settings saves), so a client can scope + // a pending restart to the subsystem it belongs to. The singular field + // above only remembers the last save. + RestartRequiredReasons []string `json:"restart_required_reasons,omitempty"` + // RestartMarkCount increments on every restart-required save. The boolean + // above latches for the process lifetime, so this is the client's only + // signal that a NEW requirement arrived after one was dismissed. + RestartMarkCount int `json:"restart_mark_count"` + RestartRequested bool `json:"restart_requested"` + RestartRequestedAt *time.Time `json:"restart_requested_at,omitempty"` } // HandleGetServerStatus handles GET /admin/server/status. func (h *AdminHandler) HandleGetServerStatus(w http.ResponseWriter, r *http.Request) { snapshot := h.RestartStatus.Snapshot() - resp := adminServerStatusResponse(snapshot) + resp := adminServerStatusResponse{ + StartedAt: snapshot.StartedAt, + RestartRequired: snapshot.RestartRequired, + RestartRequiredAt: snapshot.RestartRequiredAt, + RestartRequiredReason: snapshot.RestartRequiredReason, + RestartRequiredReasons: snapshot.RestartReasons, + RestartMarkCount: snapshot.RestartMarkCount, + RestartRequested: snapshot.RestartRequested, + RestartRequestedAt: snapshot.RestartRequestedAt, + } if h.SettingsRepo != nil { settings, err := h.SettingsRepo.GetAll(r.Context()) @@ -32,6 +51,12 @@ func (h *AdminHandler) HandleGetServerStatus(w http.ResponseWriter, r *http.Requ if resp.RestartRequiredReason == "" { resp.RestartRequiredReason = "jellyfin_compat" } + // This requirement is derived here rather than marked on the + // tracker, so the accumulated list has to gain it too — a client + // scoping restarts by reason would otherwise never see it. + if !slices.Contains(resp.RestartRequiredReasons, "jellyfin_compat") { + resp.RestartRequiredReasons = append(resp.RestartRequiredReasons, "jellyfin_compat") + } } } diff --git a/internal/api/handlers/admin_settings_checks_test.go b/internal/api/handlers/admin_settings_checks_test.go index 7b96d3a67..75552f64d 100644 --- a/internal/api/handlers/admin_settings_checks_test.go +++ b/internal/api/handlers/admin_settings_checks_test.go @@ -1813,3 +1813,80 @@ func withChiParam(r *http.Request, key, value string) *http.Request { routeCtx.URLParams.Add(key, value) return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx)) } + +func TestAdminGetSettingReportsRestartRequired(t *testing.T) { + const restartKey = "scanner.max_concurrent_libraries" + const liveKey = "server.log_level" + + handler := &AdminHandler{ + SettingsRepo: &fakeServerSettingsStore{values: map[string]string{ + restartKey: "4", + liveKey: "debug", + }}, + BootstrapSensitiveValues: map[string]string{ + "playback.ffmpeg_path": "/opt/ffmpeg", + }, + } + + for _, tc := range []struct { + name string + key string + wantValue string + wantRestart bool + wantRestartSeen bool + }{ + { + name: "stored restart-required key", + key: restartKey, + wantValue: "4", + wantRestart: true, + wantRestartSeen: true, + }, + { + name: "stored hot-reloading key omits the flag", + key: liveKey, + wantValue: "debug", + }, + { + name: "bootstrap value", + key: "playback.ffmpeg_path", + wantValue: "/opt/ffmpeg", + wantRestart: true, + wantRestartSeen: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + if config.RestartRequired(tc.key) != tc.wantRestart { + t.Fatalf("test fixture drifted: config.RestartRequired(%q) = %v", tc.key, !tc.wantRestart) + } + + req := httptest.NewRequest(http.MethodGet, "/admin/settings/"+tc.key, nil) + req = withChiParam(req, "key", tc.key) + rec := httptest.NewRecorder() + + handler.HandleGetSetting(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var payload struct { + Key string `json:"key"` + Value string `json:"value"` + RestartRequired bool `json:"restart_required"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.Key != tc.key || payload.Value != tc.wantValue { + t.Fatalf("payload = %+v, want key %q value %q", payload, tc.key, tc.wantValue) + } + if payload.RestartRequired != tc.wantRestart { + t.Fatalf("restart_required = %v, want %v", payload.RestartRequired, tc.wantRestart) + } + // omitempty must keep the flag off the wire for live keys. + if seen := strings.Contains(rec.Body.String(), "restart_required"); seen != tc.wantRestartSeen { + t.Fatalf("restart_required present = %v, want %v; body=%s", seen, tc.wantRestartSeen, rec.Body.String()) + } + }) + } +} diff --git a/internal/api/handlers/rate_limits.go b/internal/api/handlers/rate_limits.go index ce0389f4c..6ed92c43e 100644 --- a/internal/api/handlers/rate_limits.go +++ b/internal/api/handlers/rate_limits.go @@ -46,6 +46,11 @@ type rateLimitConfigResponse struct { // ActiveBackend is the backend the running limiter actually uses, which // can differ from Backend until the server restarts. ActiveBackend string `json:"active_backend,omitempty"` + // RedisAvailable reports whether the Redis backend can be selected at all, + // using the same rule the save path enforces. Sentinel and REDIS_URL + // deployments have no persisted redis.url row, so admins cannot derive + // this client-side. + RedisAvailable bool `json:"redis_available"` } type tierConfigResponse struct { @@ -83,13 +88,17 @@ type authEndpointConfigRequest struct { // HandleGetConfig handles GET /admin/rate-limits/config. func (h *RateLimitHandler) HandleGetConfig(w http.ResponseWriter, r *http.Request) { - cfg, err := ratelimit.LoadConfig(r.Context(), h.store) + // One read serves the rate values, the stored backend, and the + // Redis-availability bit, so no field of the response can straddle two + // snapshots of the settings table. + values, err := h.store.GetAll(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load rate limit config") return } + cfg := ratelimit.ConfigFromSettings(values) - backend, _ := h.store.Get(r.Context(), "ratelimit.backend") + backend := values["ratelimit.backend"] if backend == "" { backend = "memory" } @@ -104,6 +113,7 @@ func (h *RateLimitHandler) HandleGetConfig(w http.ResponseWriter, r *http.Reques IPBurst: cfg.IPBurst, AuthEndpoints: make(map[string]authEndpointConfigResponse), Active: h.mw != nil, + RedisAvailable: redisConfiguredSettings(values, h.redisBootstrapAvailable), } if h.mw != nil { resp.ActiveBackend = h.mw.ActiveBackend() diff --git a/internal/api/handlers/rate_limits_test.go b/internal/api/handlers/rate_limits_test.go index 8f212b949..d63862a49 100644 --- a/internal/api/handlers/rate_limits_test.go +++ b/internal/api/handlers/rate_limits_test.go @@ -175,6 +175,48 @@ func TestRateLimitHandlerWithoutRunningLimiter(t *testing.T) { } } +func TestRateLimitHandlerReportsRedisAvailability(t *testing.T) { + // GET must answer with the same rule the save path enforces, so the UI can + // disable the Redis option instead of failing the admin at save time. + tests := []struct { + name string + values map[string]string + bootstrapAvail bool + wantRedisEnabled bool + }{ + {name: "nothing configured"}, + { + name: "malformed persisted url", + values: map[string]string{"redis.url": "not-a-url"}, + }, + { + name: "canonical persisted url", + values: map[string]string{"redis.url": "redis://cache.example.invalid:6379"}, + wantRedisEnabled: true, + }, + { + name: "bootstrap redis despite stale persisted url", + values: map[string]string{"redis.url": " redis://cache.example.invalid:6379 "}, + bootstrapAvail: true, + wantRedisEnabled: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + store := newFakeRateLimitStore() + for key, value := range tc.values { + store.values[key] = value + } + h := NewRateLimitHandler(store, nil, nil, NewServerRestartStatusTracker(), tc.bootstrapAvail) + + if got := getRateLimitConfig(t, h).RedisAvailable; got != tc.wantRedisEnabled { + t.Errorf("GET redis_available = %v, want %v", got, tc.wantRedisEnabled) + } + }) + } +} + func TestRateLimitHandlerUsesLatestCommittedSettingsAfterAtomicWrite(t *testing.T) { baseStore := newFakeRateLimitStore() baseStore.values["ratelimit.enabled"] = "false" diff --git a/internal/api/handlers/server_restart_status.go b/internal/api/handlers/server_restart_status.go index 8be7b359d..e1ed2b558 100644 --- a/internal/api/handlers/server_restart_status.go +++ b/internal/api/handlers/server_restart_status.go @@ -1,6 +1,7 @@ package handlers import ( + "slices" "strings" "sync" "time" @@ -15,8 +16,18 @@ type ServerRestartStatusTracker struct { restartRequired bool restartRequiredAt time.Time restartRequiredReason string - restartRequested bool - restartRequestedAt time.Time + // restartMarkCount increments on every MarkRequired call. restartRequired + // latches true for the life of the process, so this counter is the only + // signal that a NEW restart-required save happened — the admin UI keys its + // banner re-arm (after "Later") on it. + restartMarkCount int + // restartReasons accumulates every distinct reason marked since boot, in + // first-seen order. The single restartRequiredReason only remembers the + // LAST save, so a tile scoped to one subsystem cannot trust it: an + // unrelated later save overwrites it. The full set can be scoped. + restartReasons []string + restartRequested bool + restartRequestedAt time.Time } type ServerRestartStatusSnapshot struct { @@ -24,6 +35,8 @@ type ServerRestartStatusSnapshot struct { RestartRequired bool RestartRequiredAt *time.Time RestartRequiredReason string + RestartReasons []string + RestartMarkCount int RestartRequested bool RestartRequestedAt *time.Time } @@ -49,8 +62,12 @@ func (s *ServerRestartStatusTracker) MarkRequired(reason string) { s.restartRequired = true s.restartRequiredAt = now } + s.restartMarkCount++ if reason != "" { s.restartRequiredReason = reason + if !slices.Contains(s.restartReasons, reason) { + s.restartReasons = append(s.restartReasons, reason) + } } } @@ -96,6 +113,8 @@ func (s *ServerRestartStatusTracker) Snapshot() ServerRestartStatusSnapshot { RestartRequired: s.restartRequired, RestartRequiredAt: restartRequiredAt, RestartRequiredReason: s.restartRequiredReason, + RestartReasons: slices.Clone(s.restartReasons), + RestartMarkCount: s.restartMarkCount, RestartRequested: s.restartRequested, RestartRequestedAt: restartRequestedAt, } diff --git a/internal/api/handlers/server_restart_status_test.go b/internal/api/handlers/server_restart_status_test.go new file mode 100644 index 000000000..823dc6576 --- /dev/null +++ b/internal/api/handlers/server_restart_status_test.go @@ -0,0 +1,29 @@ +package handlers + +import "testing" + +// The restart-required boolean latches for the life of the process, so the +// mark count is the only signal the admin UI has that a NEW restart-required +// save happened after the banner was dismissed. +func TestServerRestartStatusMarkCount(t *testing.T) { + tracker := NewServerRestartStatusTracker() + + if got := tracker.Snapshot().RestartMarkCount; got != 0 { + t.Fatalf("RestartMarkCount = %d before any mark, want 0", got) + } + + tracker.MarkRequired("ratelimit_backend") + tracker.MarkRequired("ratelimit_backend") // same reason still counts: it is a new save + tracker.MarkRequired("") + + snapshot := tracker.Snapshot() + if snapshot.RestartMarkCount != 3 { + t.Fatalf("RestartMarkCount = %d after three marks, want 3", snapshot.RestartMarkCount) + } + if !snapshot.RestartRequired { + t.Fatal("RestartRequired = false after marks, want true") + } + if snapshot.RestartRequiredReason != "ratelimit_backend" { + t.Fatalf("RestartRequiredReason = %q, want the last non-empty reason", snapshot.RestartRequiredReason) + } +} diff --git a/internal/api/handlers/settings_overlay_config_test.go b/internal/api/handlers/settings_overlay_config_test.go new file mode 100644 index 000000000..2a5d2bb6f --- /dev/null +++ b/internal/api/handlers/settings_overlay_config_test.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func readOverlayConfig(t *testing.T, handler *SettingsHandler) overlayConfigResponse { + t.Helper() + recorder := httptest.NewRecorder() + handler.HandleGetOverlayConfig( + recorder, + httptest.NewRequest(http.MethodGet, "/settings/overlay-config", nil), + ) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", recorder.Code, recorder.Body.String()) + } + var response overlayConfigResponse + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + return response +} + +func TestGetOverlayConfigDefaults(t *testing.T) { + response := readOverlayConfig(t, NewSettingsHandler(nil)) + if !response.Enabled { + t.Fatal("overlays enabled = false, want true") + } + if response.Defaults != "" { + t.Fatalf("defaults = %q, want empty", response.Defaults) + } +} + +func TestGetOverlayConfigReflectsAdminDefaults(t *testing.T) { + handler := NewSettingsHandler(nil) + handler.SetServerSettings(&fakeServerSettingsStore{values: map[string]string{ + "overlays.enabled": "false", + "defaults.card_overlays": `{"preset":"classic"}`, + }}) + + response := readOverlayConfig(t, handler) + if response.Enabled { + t.Fatal("overlays enabled = true, want false") + } + if response.Defaults != `{"preset":"classic"}` { + t.Fatalf("defaults = %q", response.Defaults) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 08cdd965b..f3920e75a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1197,6 +1197,11 @@ func NewRouter(deps Dependencies) chi.Router { adminHandler.RestartStatus = restartStatus adminHandler.CatalogSearchStatus = catalogSearchService adminHandler.DiagnosticsStore = diagnosticsStore + // Same source branding asset uploads and the metadata image cacher use: + // the public S3 client only exists when a public bucket is configured, + // and both features are wired off it. + publicAssetStore := deps.S3Public + adminHandler.PublicStorageConfigured = func() bool { return publicAssetStore != nil } if settingsRepo != nil { adminHandler.SettingsRepo = settingsRepo } @@ -1567,19 +1572,12 @@ func NewRouter(deps Dependencies) chi.Router { client: tmdb.NewClient(apiKey, 40), } } - traktClientID := "" - if settingsRepo != nil { - ctx := deps.AppContext - if ctx == nil { - ctx = context.Background() - } - if value, err := settingsRepo.Get(ctx, "watchsync.trakt.client_id"); err == nil { - traktClientID = value - } - } if libraryCollectionService.TraktCollections == nil { + // The client ID is resolved per call rather than captured here, so + // saving new Trakt credentials applies without a server restart. libraryCollectionService.TraktCollections = &traktCollectionAdapter{ - client: metatrakt.NewClient(traktClientID, 5), + client: metatrakt.NewClient("", 5), + settings: settingsRepo, } } if libraryCollectionService.TraktTokenResolver == nil && deps.DB != nil && settingsRepo != nil { @@ -3026,6 +3024,7 @@ func NewRouter(deps Dependencies) chi.Router { r.Post("/jellyfin-compat/web/update", adminHandler.HandleUpdateJellyfinCompatWeb) r.Post("/jellyfin-compat/web/remove", adminHandler.HandleRemoveJellyfinCompatWeb) r.Get("/settings/sensitive-status", adminHandler.HandleGetSensitiveStatus) + r.Get("/settings/restart-keys", adminHandler.HandleGetRestartKeys) r.Post("/settings/check/{kind}", adminHandler.HandleCheckSettingsConnection) if sectionSettingsHandler != nil { r.Get("/settings/sections", sectionSettingsHandler.HandleGet) @@ -3742,11 +3741,33 @@ func (a *tmdbDiscoverAdapter) Discover(ctx context.Context, mediaType string, pa return entries, nil } +// traktClientIDSettingKey holds the Trakt app client ID. It is deliberately +// not in config.restartRequiredKeys: the adapter re-reads it before every +// upstream call, so a saved change converges without a restart. +const traktClientIDSettingKey = "watchsync.trakt.client_id" + type traktCollectionAdapter struct { client *metatrakt.Client + // settings is the live source of the app client ID. Nil only where no + // settings store exists (tests), where the client ID stays empty and the + // upstream call fails the same way it always did. + settings catalog.SettingsStore +} + +// refreshClientID pushes the currently saved app client ID onto the shared +// client. A read failure leaves the last known value in place: failing the +// request at Trakt is more useful than failing it here on a transient DB blip. +func (a *traktCollectionAdapter) refreshClientID(ctx context.Context) { + if a.settings == nil { + return + } + if clientID, err := a.settings.Get(ctx, traktClientIDSettingKey); err == nil { + a.client.SetClientID(clientID) + } } func (a *traktCollectionAdapter) GetCollectionPreset(ctx context.Context, preset, mediaType string, limit int, accessToken string) ([]catalog.TraktCollectionEntry, error) { + a.refreshClientID(ctx) results, err := a.client.GetCollectionPreset(ctx, preset, mediaType, limit, accessToken) if err != nil { return nil, err @@ -3768,6 +3789,7 @@ func (a *traktCollectionAdapter) GetCollectionPreset(ctx context.Context, preset } func (a *traktCollectionAdapter) GetUserList(ctx context.Context, user, list string, limit int, accessToken string) ([]catalog.TraktCollectionEntry, error) { + a.refreshClientID(ctx) results, err := a.client.GetUserList(ctx, user, list, limit, accessToken) if err != nil { return nil, err diff --git a/internal/api/testdata/media_routes.txt b/internal/api/testdata/media_routes.txt index 39c0431ac..907783a99 100644 --- a/internal/api/testdata/media_routes.txt +++ b/internal/api/testdata/media_routes.txt @@ -143,6 +143,7 @@ GET /api/v1/admin/settings non-media PUT /api/v1/admin/settings non-media POST /api/v1/admin/settings/check/{kind} non-media GET /api/v1/admin/settings/effective non-media +GET /api/v1/admin/settings/restart-keys non-media GET /api/v1/admin/settings/sections non-media PUT /api/v1/admin/settings/sections non-media GET /api/v1/admin/settings/sensitive-status non-media diff --git a/internal/chapterthumbs/service.go b/internal/chapterthumbs/service.go index 03cb34ea4..ea9ad6425 100644 --- a/internal/chapterthumbs/service.go +++ b/internal/chapterthumbs/service.go @@ -37,6 +37,14 @@ const ( chapterThumbnailHDRPolicyDisabled = "disabled" chapterThumbnailHDRPolicyBestEffort = "best_effort" chapterThumbnailSoftwareToneMapSetting = "playback.chapter_thumbnail_software_tone_map_enabled" + + // Hardware acceleration is read per extraction from these keys, not frozen + // at startup, so an admin change applies to the next chapter thumbnail. + // playbackHWAccelDefault mirrors the config loader's default for an unset + // row (internal/config/admin_settings.go). + playbackHWAccelSetting = "playback.hw_accel" + playbackHWDeviceSetting = "playback.hw_device" + playbackHWAccelDefault = "auto" ) var chapterThumbnailRetrySchedule = []time.Duration{ @@ -94,16 +102,28 @@ type ChapterThumbnailRequest struct { } type Service struct { - fileRepo FileRepository - folderRepo FolderRepository - probeEnsurer ProbeEnsurer - settings SettingsReader - store ObjectStore - notifier ThumbnailNotifier - ffmpegPath string - hwAccel string - hwDevice string - hwResolveOnce sync.Once + fileRepo FileRepository + folderRepo FolderRepository + probeEnsurer ProbeEnsurer + settings SettingsReader + store ObjectStore + notifier ThumbnailNotifier + ffmpegPath string + // hwAccel and hwDevice hold the playback.hw_accel / playback.hw_device + // values captured when the service was built. They are only the fallback: + // resolveHWConfig re-reads both settings per extraction so an admin who + // changes hardware acceleration does not have to restart the server for + // chapter-thumbnail extraction to follow. + hwAccel string + hwDevice string + + // hwMu guards the resolved-accelerator memo below. Resolving "auto" execs + // an FFmpeg capability probe and logs the verdict, so the result is cached + // against the configured value that produced it and recomputed only when + // that value actually changes. + hwMu sync.Mutex + hwResolved bool + hwResolvedFrom string resolvedHWAccel string notifyNormal chan struct{} @@ -207,7 +227,9 @@ func (s *Service) Start(ctx context.Context) { return } - resolvedAccel, resolvedDevice := s.resolveHWConfig() + // Logged for the boot record only: both values are re-read per extraction, + // so a later settings change is honored without a restart. + resolvedAccel, resolvedDevice := s.resolveHWConfig(ctx) slog.InfoContext(ctx, "chapter thumbnail service started", "component", "chapterthumbs", "workers", @@ -650,7 +672,7 @@ func (s *Service) extractFrameLocal( toneMap bool, allowSoftwareToneMap bool, ) ([]byte, string, error) { - resolvedAccel, resolvedDevice := s.resolveHWConfig() + resolvedAccel, resolvedDevice := s.resolveHWConfig(ctx) return ExtractFrame(ctx, FrameExtractOptions{ InputPath: inputPath, SeekSeconds: seekSeconds, @@ -663,13 +685,62 @@ func (s *Service) extractFrameLocal( }) } -func (s *Service) resolveHWConfig() (string, string) { - s.hwResolveOnce.Do(func() { - s.resolvedHWAccel = playback.ResolveHWAccelWithFFmpeg(s.hwAccel, s.ffmpegPath) - }) +// resolveHWConfig returns the accelerator and device this extraction should +// use. Both come from the live settings repo rather than from a value frozen at +// startup, which is what lets playback.hw_accel / playback.hw_device take +// effect without a server restart. +func (s *Service) resolveHWConfig(ctx context.Context) (string, string) { + configuredAccel, configuredDevice := s.configuredHWConfig(ctx) + + s.hwMu.Lock() + defer s.hwMu.Unlock() + if !s.hwResolved || s.hwResolvedFrom != configuredAccel { + s.resolvedHWAccel = playback.ResolveHWAccelWithFFmpeg(configuredAccel, s.ffmpegPath) + s.hwResolvedFrom = configuredAccel + s.hwResolved = true + } // The configured device value passes through raw: ExtractFrame resolves it // (multi-device balancing, empty-value auto-detection) per extraction. - return s.resolvedHWAccel, s.hwDevice + return s.resolvedHWAccel, configuredDevice +} + +// configuredHWConfig reads playback.hw_accel / playback.hw_device from the +// settings repo, mirroring how the config loader defaults them. A settings repo +// that is absent (test doubles) or failing falls back to the values captured at +// construction, so a database blip keeps the boot configuration instead of +// silently dropping extraction to software. +func (s *Service) configuredHWConfig(ctx context.Context) (string, string) { + if s == nil { + return "", "" + } + accel := s.hwAccel + if value, ok := s.readSetting(ctx, playbackHWAccelSetting); ok { + accel = value + if accel == "" { + accel = playbackHWAccelDefault + } + } + device := s.hwDevice + if value, ok := s.readSetting(ctx, playbackHWDeviceSetting); ok { + // An empty device is a meaningful value ("auto-detect one"), so unlike + // the accelerator it is not replaced by a default. + device = value + } + return accel, device +} + +// readSetting reports the trimmed setting value and whether the settings repo +// answered at all. The second result is what lets callers tell "configured +// empty" apart from "could not read". +func (s *Service) readSetting(ctx context.Context, key string) (string, bool) { + if s == nil || s.settings == nil { + return "", false + } + value, err := s.settings.Get(ctx, key) + if err != nil { + return "", false + } + return strings.TrimSpace(value), true } func (s *Service) chapterThumbnailExecutionMode(ctx context.Context) string { diff --git a/internal/chapterthumbs/service_test.go b/internal/chapterthumbs/service_test.go index c2bceeb4b..062db2f05 100644 --- a/internal/chapterthumbs/service_test.go +++ b/internal/chapterthumbs/service_test.go @@ -874,3 +874,64 @@ func TestExtractFrameResolvesMultiDeviceListToOneDevice(t *testing.T) { t.Fatalf("ffmpeg args missing a resolved device:\n%s", joined) } } + +// failingSettingsReader stands in for a settings repo that cannot answer, so +// the fallback path can be told apart from a configured-empty value. +type failingSettingsReader struct{} + +func (failingSettingsReader) Get(_ context.Context, _ string) (string, error) { + return "", errors.New("settings unavailable") +} + +// TestResolveHWConfigFollowsLiveSettings is the regression guard for the +// restart-required conversion: hardware acceleration is read from the settings +// repo per extraction, so an admin changing playback.hw_accel or +// playback.hw_device does not have to restart the server for chapter +// thumbnails to follow. +func TestResolveHWConfigFollowsLiveSettings(t *testing.T) { + values := map[string]string{ + "playback.hw_accel": "vaapi", + "playback.hw_device": "/dev/dri/renderD128", + } + service := &Service{ + // Deliberately different from the settings rows: the boot values must + // not win over the live configuration. + hwAccel: "none", + hwDevice: "/dev/dri/renderD200", + settings: testSettingsReader{values: values}, + } + + accel, device := service.resolveHWConfig(context.Background()) + if accel != "vaapi" || device != "/dev/dri/renderD128" { + t.Fatalf("resolveHWConfig() = (%q, %q), want (vaapi, /dev/dri/renderD128)", accel, device) + } + + values["playback.hw_accel"] = "qsv" + values["playback.hw_device"] = "/dev/dri/renderD129" + + accel, device = service.resolveHWConfig(context.Background()) + if accel != "qsv" || device != "/dev/dri/renderD129" { + t.Fatalf("resolveHWConfig() after settings change = (%q, %q), want (qsv, /dev/dri/renderD129)", accel, device) + } + + // An emptied device row means "auto-detect", not "keep the previous one". + values["playback.hw_device"] = "" + if _, device = service.resolveHWConfig(context.Background()); device != "" { + t.Fatalf("resolveHWConfig() device after clearing = %q, want empty", device) + } +} + +// TestResolveHWConfigFallsBackWhenSettingsUnavailable keeps a database blip +// from silently switching extraction off the configured accelerator. +func TestResolveHWConfigFallsBackWhenSettingsUnavailable(t *testing.T) { + service := &Service{ + hwAccel: "vaapi", + hwDevice: "/dev/dri/renderD128", + settings: failingSettingsReader{}, + } + + accel, device := service.resolveHWConfig(context.Background()) + if accel != "vaapi" || device != "/dev/dri/renderD128" { + t.Fatalf("resolveHWConfig() = (%q, %q), want the boot values", accel, device) + } +} diff --git a/internal/config/admin_settings.go b/internal/config/admin_settings.go index 5798ac0c3..84dc000e9 100644 --- a/internal/config/admin_settings.go +++ b/internal/config/admin_settings.go @@ -43,6 +43,10 @@ const ArtworkStorageReconcileCheckpointKey = "s3.public_storage_reconcile_checkp // readers that own each setting. The UI must never invent a second set of // defaults: an untouched form should describe the behavior the server is // actually running. +// Setting keys and default values are a data table; naming each repeated +// literal would bury what the table says. +// +//nolint:goconst var adminSettingDefaults = map[string]string{ "auth.access_token_expiry": "8h", "auth.refresh_token_expiry": "30d", @@ -53,23 +57,30 @@ var adminSettingDefaults = map[string]string{ "clientip.trusted_proxies": "10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, ::1/128", "theme.catalog_url": DefaultThemeCatalogURL, - "database.max_connections": "20", - "s3.public_path_style": "true", - "s3.public_url_auth": "presigned", - "s3.public_token_param": "verify", - "s3.public_token_ttl": "10800", - "s3.private_path_style": "true", - "s3.user_db_path_style": "true", - "userdb.backend": "postgres", - "userdb.pool_max_open": "500", - "userdb.idle_timeout": "12h", - - "scanner.workers": "8", - "matcher.workers": "8", - "matcher.batch_size": "500", - "metadata.cache_images": "false", - "markers.mode": "local", - "markers.lazy_playback": "false", + "database.max_connections": "20", + "s3.public_path_style": "true", + "s3.public_url_auth": "presigned", + "s3.public_token_param": "verify", + "s3.public_token_ttl": "10800", + "s3.private_path_style": "true", + "s3.metadata_presign_expiry": "4h", + "s3.user_db_path_style": "true", + "userdb.backend": "postgres", + "userdb.pool_max_open": "500", + "userdb.idle_timeout": "12h", + + "scanner.workers": "8", + "scanner.max_concurrent_libraries": "1", + "scanner.max_concurrent_scoped": "2", + "scanner.file_removal_grace": "24h", + "scanner.empty_trash_after_scan": "true", + "matcher.workers": "8", + "matcher.batch_size": "500", + "matcher.enable_tv_series_root_queue": "true", + "matcher.enable_tv_series_group_queue": "false", + "metadata.cache_images": "false", + "markers.mode": "local", + "markers.lazy_playback": "false", "playback.ffmpeg_path": "", playbackTranscodeDirSettingKey: DefaultTranscodeDir, @@ -101,6 +112,8 @@ var adminSettingDefaults = map[string]string{ "jellyfin_compat.playback_session_ttl": "6h", "recommendations.enabled": "false", + "recommendations.embedding_provider": "ollama", + "recommendations.embeddings_job_timeout": "24h", "recommendations.embedding_base_url": "http://ollama:11434", "recommendations.embedding_model": "all-minilm", "recommendations.embeddings_cron": "0 3 * * *", @@ -119,6 +132,7 @@ var adminSettingDefaults = map[string]string{ "subtitle_ai.batch_size": "40", "subtitle_ai.context_neighbors": "2", "subtitle_ai.asr_chunk_seconds": "600", + "subtitle_ai.live_asr_chunk_seconds": "30", "subtitle_ai.transcribe_quota_jobs": "0", "subtitle_ai.transcribe_quota_period": "day", "metadata_ai.enabled": "false", @@ -134,6 +148,8 @@ var adminSettingDefaults = map[string]string{ "download.max_concurrent_prepares": "2", "download.artifact_max_bytes": "0", + "policy.editor_enabled": "false", + "policy.eval_timeout_ms": "25", "policy.decision_log_verbosity": "digest", "policy.decision_log_scope_sample_rate": "50", "policy.decision_log_retention_days": "14", @@ -173,6 +189,7 @@ var adminSettingDefaults = map[string]string{ "taskmanager.history_retention_days": "30", "taskmanager.history_keep_per_task": "1000", + "opslog.capture_level": "info", "opslog.retention_days": "7", "opslog.cleanup_interval_minutes": "15", "opslog.max_rows": "1000000", @@ -302,6 +319,8 @@ func NormalizeAdminSetting(key, raw string) (string, error) { "jellyfin_compat.enabled", "jellyfin_compat.web_enabled", "recommendations.enabled", "subtitle_ai.enabled", "subtitle_ai.transcribe_enabled", "metadata_ai.enabled", "download.enabled", "download.transcode_enabled", "email.enabled", "signup.enabled", + "scanner.empty_trash_after_scan", "matcher.enable_tv_series_root_queue", + "matcher.enable_tv_series_group_queue", "policy.editor_enabled", "overlays.enabled", "notifications.release_events_enabled", "notifications.fanout_enabled", "notifications.ui_enabled", "notifications.webhooks_enabled", "notifications.webhooks.allow_private_destinations", "notifications.email_enabled", @@ -317,7 +336,8 @@ func NormalizeAdminSetting(key, raw string) (string, error) { return normalizeAdminInt(key, value, 1, 10000) case "userdb.pool_max_open": return normalizeAdminInt(key, value, 1, 100000) - case "scanner.workers", "matcher.workers": + case "scanner.workers", "matcher.workers", + "scanner.max_concurrent_libraries", "scanner.max_concurrent_scoped": return normalizeAdminInt(key, value, 1, 1024) case "matcher.batch_size": return normalizeAdminInt(key, value, 1, 100000) @@ -337,6 +357,10 @@ func NormalizeAdminSetting(key, raw string) (string, error) { return normalizeAdminInt(key, value, 0, 100) case "subtitle_ai.asr_chunk_seconds": return normalizeAdminInt(key, value, 60, 600) + case "subtitle_ai.live_asr_chunk_seconds": + // 15s is the transcriber's hard floor (clampASRChunkSeconds); accepting + // less would store a value the runtime silently raises. + return normalizeAdminInt(key, value, 15, 600) case "subtitle_ai.transcribe_quota_jobs": return normalizeAdminInt(key, value, 0, math.MaxInt32) case "download.server_bandwidth_mbps", "download.user_bandwidth_mbps": @@ -346,6 +370,8 @@ func NormalizeAdminSetting(key, raw string) (string, error) { return normalizeAdminInt64(key, value, 0, math.MaxInt64) case "policy.decision_log_scope_sample_rate", "policy.decision_log_retention_days": return normalizeAdminInt(key, value, 1, math.MaxInt32) + case "policy.eval_timeout_ms": + return normalizeAdminInt(key, value, 1, 60000) case "email.smtp_port": return normalizeAdminInt(key, value, 1, 65535) case "notifications.fanout.settle_seconds": @@ -391,11 +417,24 @@ func NormalizeAdminSetting(key, raw string) (string, error) { case "auth.access_token_expiry", "auth.refresh_token_expiry", "userdb.idle_timeout", "download.period_duration", "jellyfin_compat.session_ttl", - "jellyfin_compat.playback_session_ttl": + "jellyfin_compat.playback_session_ttl", "s3.metadata_presign_expiry", + "recommendations.embeddings_job_timeout": return normalizeAdminDuration(key, value) + case "scanner.file_removal_grace": + // The scanner deliberately tolerates a zero or negative grace as + // "remove missing files immediately" (LoadFromDB warns and clamps to + // zero), so only require a parseable duration here. + if _, err := parseDuration(value); err != nil { + return "", fmt.Errorf("%s must be a duration", key) + } + return value, nil case "server.log_level": return normalizeAdminEnum(key, value, "debug", "info", "warn", "error") + case "opslog.capture_level": + // "warning" is accepted because the startup reader in cmd/silo treats + // it as an alias for "warn". + return normalizeAdminEnum(key, value, "debug", "info", "warn", "warning", "error") case "userdb.backend": return normalizeAdminEnum(key, value, "postgres", "sqlite") case "playback.hw_accel": diff --git a/internal/config/admin_settings_test.go b/internal/config/admin_settings_test.go index 65d01124d..ba184da4c 100644 --- a/internal/config/admin_settings_test.go +++ b/internal/config/admin_settings_test.go @@ -241,6 +241,18 @@ func TestNormalizeAdminSettingRejectsInvalidValues(t *testing.T) { {key: "theme.catalog_url", value: "http://raw.githubusercontent.com/Prairie-Server/silo-themes/main/catalog.json"}, {key: "theme.catalog_url", value: "https://example.com/catalog.json"}, {key: "redis.url", value: "not-a-url"}, + {key: "scanner.max_concurrent_libraries", value: "0"}, + {key: "scanner.max_concurrent_scoped", value: "-1"}, + {key: "scanner.empty_trash_after_scan", value: "sometimes"}, + {key: "scanner.file_removal_grace", value: "a while"}, + {key: "matcher.enable_tv_series_root_queue", value: "yes please"}, + {key: "matcher.enable_tv_series_group_queue", value: "yes please"}, + {key: "policy.editor_enabled", value: "maybe"}, + {key: "policy.eval_timeout_ms", value: "0"}, + {key: "subtitle_ai.live_asr_chunk_seconds", value: "0"}, + {key: "opslog.capture_level", value: "chatty"}, + {key: "s3.metadata_presign_expiry", value: "0s"}, + {key: "recommendations.embeddings_job_timeout", value: "soon"}, } for _, tc := range tests { t.Run(tc.key, func(t *testing.T) { @@ -324,3 +336,59 @@ func TestNormalizeAdminSettingCanonicalizesRedisURL(t *testing.T) { t.Fatalf("normalized Redis URL = %q", got) } } + +// TestNormalizeAdminSettingKeepsPermissiveScannerGrace locks the loader's +// documented behavior: a zero or negative grace means "remove missing files +// immediately", so the admin API must not reject it. +func TestNormalizeAdminSettingKeepsPermissiveScannerGrace(t *testing.T) { + for _, value := range []string{"0s", "-1h", "72h"} { + got, err := NormalizeAdminSetting("scanner.file_removal_grace", " "+value+" ") + if err != nil { + t.Fatalf("NormalizeAdminSetting(scanner.file_removal_grace, %q): %v", value, err) + } + if got != value { + t.Fatalf("normalized grace = %q, want %q", got, value) + } + } +} + +// TestOpslogCaptureLevelAcceptsWarningAlias mirrors the startup reader in +// cmd/silo, which treats "warning" as "warn". +func TestOpslogCaptureLevelAcceptsWarningAlias(t *testing.T) { + got, err := NormalizeAdminSetting("opslog.capture_level", "WARNING") + if err != nil { + t.Fatal(err) + } + if got != "warning" { + t.Fatalf("normalized capture level = %q, want warning", got) + } +} + +// TestHiddenTierDefaultsAreExposed guards the keys that have no admin UI: the +// API must still report the value the server is actually running. +func TestHiddenTierDefaultsAreExposed(t *testing.T) { + effective := EffectiveAdminSettings(nil) + want := map[string]string{ + "recommendations.embedding_provider": "ollama", + "recommendations.embeddings_job_timeout": "24h", + "policy.editor_enabled": "false", + "policy.eval_timeout_ms": "25", + "subtitle_ai.live_asr_chunk_seconds": "30", + "scanner.max_concurrent_libraries": "1", + "scanner.max_concurrent_scoped": "2", + "scanner.file_removal_grace": "24h", + "scanner.empty_trash_after_scan": "true", + "matcher.enable_tv_series_root_queue": "true", + "matcher.enable_tv_series_group_queue": "false", + "opslog.capture_level": "info", + "s3.metadata_presign_expiry": "4h", + } + for key, value := range want { + if got := effective[key]; got != value { + t.Errorf("effective[%q] = %q, want %q", key, got, value) + } + if _, err := NormalizeAdminSetting(key, value); err != nil { + t.Errorf("default for %q is rejected by NormalizeAdminSetting: %v", key, err) + } + } +} diff --git a/internal/config/artifact_dir_test.go b/internal/config/artifact_dir_test.go new file mode 100644 index 000000000..746465df5 --- /dev/null +++ b/internal/config/artifact_dir_test.go @@ -0,0 +1,31 @@ +package config + +import "testing" + +func TestEffectiveDownloadArtifactDir(t *testing.T) { + cases := []struct { + name string + artifactDir string + transcodeDir string + want string + }{ + {"explicit artifact dir wins", "/mnt/downloads", "/mnt/fast/transcode", "/mnt/downloads"}, + {"both blank uses the default transcode dir", "", "", "/tmp/silo-download-artifacts"}, + {"sibling of a custom transcode dir", "", "/mnt/fast/transcode", "/mnt/fast/silo-download-artifacts"}, + // A trailing slash must not nest the artifact root inside the + // transcode dir: the orphaned-transcode sweep deletes non-active + // subdirectories of the transcode root, so nesting is data loss. + {"trailing slash still yields the sibling", "", "/mnt/fast/transcode/", "/mnt/fast/silo-download-artifacts"}, + {"root transcode dir", "", "/", "/silo-download-artifacts"}, + {"repeated separators are cleaned", "", "/mnt//fast/transcode", "/mnt/fast/silo-download-artifacts"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := EffectiveDownloadArtifactDir(tc.artifactDir, tc.transcodeDir); got != tc.want { + t.Fatalf("EffectiveDownloadArtifactDir(%q, %q) = %q, want %q", + tc.artifactDir, tc.transcodeDir, got, tc.want) + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 15028e387..779248963 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -495,7 +495,11 @@ func EffectiveDownloadArtifactDir(artifactDir, transcodeDir string) string { if transcodeDir == "" { transcodeDir = DefaultTranscodeDir } - return filepath.Join(filepath.Dir(transcodeDir), "silo-download-artifacts") + // Clean first: with a trailing slash, filepath.Dir("/srv/transcode/") is + // "/srv/transcode", which would nest the artifact root INSIDE the transcode + // dir — where the orphaned-transcode sweep deletes non-active + // subdirectories, i.e. it would delete prepared downloads. + return filepath.Join(filepath.Dir(filepath.Clean(transcodeDir)), "silo-download-artifacts") } const DefaultJellyfinCompatEmulatedServerVersion = "10.12.0" diff --git a/internal/config/restart_keys.go b/internal/config/restart_keys.go index d519d9fdc..17eb3e96c 100644 --- a/internal/config/restart_keys.go +++ b/internal/config/restart_keys.go @@ -1,6 +1,9 @@ package config -import "strings" +import ( + "sort" + "strings" +) // restartRequiredKeys lists server_settings keys whose values are captured at // process startup (listeners, connection pools, HTTP clients, worker pools) @@ -30,12 +33,21 @@ var restartRequiredKeys = map[string]bool{ "ratelimit.enabled": true, "ratelimit.backend": true, - // Playback transcode infrastructure. The playback/stream handlers read - // ffmpeg path and hwaccel live (new transcode sessions), but several - // startup-built consumers still freeze them (scanner ffprobe, chapter - // thumbnails, audiobook enricher) — keep restart-required until those - // convert. transcode_dir is also captured by dedicated transcode nodes for - // session and prepared-download storage. A configured download.artifact_dir is + // Playback transcode infrastructure. The native playback/stream handlers, + // the transcode nodes, and the download artifact managers read ffmpeg path + // and hwaccel live (new transcode sessions), and chapter-thumbnail + // extraction and the playback probe ensurer now do too. + // + // What still freezes them is the jellycompat playback handler: it captures + // FFmpegPath/HWAccel and the boot *config.Config (for hw_device) when the + // compat router is built, so a Jellyfin-client transcode keeps the boot + // values until restart. ffmpeg_path has three more startup-frozen consumers + // in cmd/silo/main.go — the intro-marker analyzer, the scanner's own ffprobe + // path, and the audiobook enricher. Keep these restart-required until those + // convert; converting them is what lets the badge go away. + // + // transcode_dir is also captured by dedicated transcode nodes for session + // and prepared-download storage. A configured download.artifact_dir is // likewise captured by both API and transcode-node artifact managers. The // chapter-thumbnail worker pool is sized at construction. "playback.ffmpeg_path": true, @@ -55,12 +67,11 @@ var restartRequiredKeys = map[string]bool{ "matcher.enable_tv_series_root_queue": true, "matcher.enable_tv_series_group_queue": true, - // External API clients built once at startup. + // External API clients built once at startup. watchsync.trakt.client_id is + // deliberately absent: the collection adapter re-reads it from the settings + // repo before each upstream call (atomic setter on the shared client), and + // the watch-sync OAuth flows always read both credentials live. "tmdb.api_key": true, - // The Trakt collection browser captures its public client ID when the - // router is built. Watch-sync flows read both credentials live, but a - // restart is still required for the collection adapter to converge. - "watchsync.trakt.client_id": true, // Compat listeners and session stores. "audiobookshelf_compat.enabled": true, @@ -119,3 +130,28 @@ func RestartRequired(key string) bool { } return false } + +// RestartRequiredKeys returns the sorted list of exact server_settings keys +// that require a restart. The admin UI reads this over +// GET /admin/settings/restart-keys to render its restart badges, so it must +// stay a copy: callers must not be able to mutate the registry. +func RestartRequiredKeys() []string { + keys := make([]string, 0, len(restartRequiredKeys)) + for key, required := range restartRequiredKeys { + if required { + keys = append(keys, key) + } + } + sort.Strings(keys) + return keys +} + +// RestartRequiredPrefixes returns the sorted list of key prefixes whose whole +// namespace requires a restart. Pair it with RestartRequiredKeys: a key needs a +// restart when it is listed exactly or carries one of these prefixes. +func RestartRequiredPrefixes() []string { + prefixes := make([]string, len(restartRequiredPrefixes)) + copy(prefixes, restartRequiredPrefixes) + sort.Strings(prefixes) + return prefixes +} diff --git a/internal/config/restart_keys_test.go b/internal/config/restart_keys_test.go index a81b773ad..4924bca4f 100644 --- a/internal/config/restart_keys_test.go +++ b/internal/config/restart_keys_test.go @@ -58,3 +58,36 @@ func TestRestartRequired(t *testing.T) { } } } + +// The exported accessors feed the admin API, so they must agree with +// RestartRequired, stay sorted, and hand out copies rather than the registry. +func TestRestartRequiredAccessors(t *testing.T) { + keys := RestartRequiredKeys() + if len(keys) != len(restartRequiredKeys) { + t.Fatalf("RestartRequiredKeys() returned %d keys, want %d", len(keys), len(restartRequiredKeys)) + } + for i, key := range keys { + if i > 0 && keys[i-1] >= key { + t.Fatalf("RestartRequiredKeys() is not sorted at %d: %v", i, keys) + } + if !RestartRequired(key) { + t.Errorf("RestartRequiredKeys() reported %q, which RestartRequired rejects", key) + } + } + + prefixes := RestartRequiredPrefixes() + if len(prefixes) != len(restartRequiredPrefixes) { + t.Fatalf("RestartRequiredPrefixes() returned %d prefixes, want %d", len(prefixes), len(restartRequiredPrefixes)) + } + for _, prefix := range prefixes { + if !RestartRequired(prefix + "anything") { + t.Errorf("RestartRequiredPrefixes() reported %q, which RestartRequired rejects", prefix) + } + } + + // Mutating a returned slice must not corrupt the registry. + prefixes[0] = "mutated." + if RestartRequiredPrefixes()[0] == "mutated." { + t.Error("RestartRequiredPrefixes() exposes the package-level slice") + } +} diff --git a/internal/jellycompat/playback_scrobble_test.go b/internal/jellycompat/playback_scrobble_test.go index 4816abd55..71eca17ca 100644 --- a/internal/jellycompat/playback_scrobble_test.go +++ b/internal/jellycompat/playback_scrobble_test.go @@ -32,6 +32,11 @@ type channelCompatWatchScrobbler struct { failStops int } +type terminalReleaseObservingStore struct { + CompatPlaybackStore + releases chan struct{} +} + type failingCompatWatchScrobbler struct { stopCalls atomic.Int32 } @@ -199,6 +204,17 @@ func (s *channelCompatWatchScrobbler) ScrobbleStopConfirmed(ctx context.Context, return s.ScrobbleStop(ctx, event) } +func (s *terminalReleaseObservingStore) ReleaseTerminalClaim( + id string, + compatToken string, + claimUntil time.Time, + claimVersion int64, + fallbackSent bool, +) { + s.CompatPlaybackStore.ReleaseTerminalClaim(id, compatToken, claimUntil, claimVersion, fallbackSent) + s.releases <- struct{}{} +} + func (s *recordingCompatWatchScrobbler) ScrobbleStart(_ context.Context, event watchsync.ScrobbleEvent) error { s.calls = append(s.calls, compatScrobbleCall{action: "start", event: event}) return nil @@ -601,6 +617,11 @@ func TestActiveEncodingsFallbackAllowsLaterAuthoritativeStop(t *testing.T) { }, }} h, store := newActiveEncodingsHandler(mgr) + releases := make(chan struct{}, 2) + h.playbackStore = &terminalReleaseObservingStore{ + CompatPlaybackStore: store, + releases: releases, + } scrobbler := &channelCompatWatchScrobbler{stopEvents: make(chan watchsync.ScrobbleEvent, 2)} h.WatchScrobbler = scrobbler h.terminalFallbackDelay = 10 * time.Millisecond @@ -631,6 +652,11 @@ func TestActiveEncodingsFallbackAllowsLaterAuthoritativeStop(t *testing.T) { case <-time.After(time.Second): t.Fatal("timed out waiting for ActiveEncodings terminal fallback") } + select { + case <-releases: + case <-time.After(time.Second): + t.Fatal("timed out waiting for fallback delivery lease release") + } terminal, ok := store.GetFinalizable("play-1", "token-1") if !ok || !terminal.TerminalFallbackSent || terminal.TerminalAuthoritative { t.Fatalf("fallback terminal state = ok=%v session=%+v", ok, terminal) diff --git a/internal/metadata/trakt/client.go b/internal/metadata/trakt/client.go index 242e242bb..d5a5c5c4e 100644 --- a/internal/metadata/trakt/client.go +++ b/internal/metadata/trakt/client.go @@ -10,6 +10,7 @@ import ( "net/url" "strconv" "strings" + "sync/atomic" "time" "golang.org/x/time/rate" @@ -28,22 +29,44 @@ const ( // Client is an HTTP client for Trakt collection/discovery feeds. type Client struct { httpClient *http.Client - clientID string - baseURL string - limiter *rate.Limiter + // clientID is swapped atomically so a saved credential change reaches a + // long-lived client without rebuilding it — and therefore without a + // server restart. See SetClientID. + clientID atomic.Pointer[string] + baseURL string + limiter *rate.Limiter } -// NewClient creates a Trakt client. clientID is required by Trakt for API calls. +// NewClient creates a Trakt client. clientID is required by Trakt for API +// calls, but may be empty here when the caller keeps it current through +// SetClientID. func NewClient(clientID string, rateLimit int) *Client { if rateLimit <= 0 { rateLimit = defaultCollectionRateLimit } - return &Client{ + c := &Client{ httpClient: &http.Client{Timeout: 20 * time.Second}, - clientID: strings.TrimSpace(clientID), baseURL: defaultBaseURL, limiter: rate.NewLimiter(rate.Limit(rateLimit), rateLimit), } + c.SetClientID(clientID) + return c +} + +// SetClientID replaces the app client ID sent on subsequent requests. Safe to +// call while requests are in flight, which is what lets a new +// watchsync.trakt.client_id apply without restarting the server. +func (c *Client) SetClientID(clientID string) { + trimmed := strings.TrimSpace(clientID) + c.clientID.Store(&trimmed) +} + +// ClientID returns the app client ID currently in use. +func (c *Client) ClientID() string { + if current := c.clientID.Load(); current != nil { + return *current + } + return "" } // SetBaseURL overrides the API base URL. Used by tests. @@ -223,7 +246,10 @@ func (c *Client) getMediaList(ctx context.Context, path, mediaType, accessToken } func (c *Client) doGet(ctx context.Context, path string, accessToken string, dest any) error { - if strings.TrimSpace(c.clientID) == "" { + // Read once so every retry of this request uses one client ID even if a + // credential change lands mid-flight. + clientID := c.ClientID() + if clientID == "" { return errors.New("trakt: client id is required") } if err := c.limiter.Wait(ctx); err != nil { @@ -239,7 +265,7 @@ func (c *Client) doGet(ctx context.Context, path string, accessToken string, des req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("trakt-api-version", traktAPIVersion) - req.Header.Set("trakt-api-key", c.clientID) + req.Header.Set("trakt-api-key", clientID) if strings.TrimSpace(accessToken) != "" { req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(accessToken)) } diff --git a/internal/metadata/trakt/client_test.go b/internal/metadata/trakt/client_test.go index b055bcbca..fc3198358 100644 --- a/internal/metadata/trakt/client_test.go +++ b/internal/metadata/trakt/client_test.go @@ -118,6 +118,35 @@ func TestGetCollectionPresetRetriesRateLimit(t *testing.T) { } } +// A saved credential change has to reach a client that was built at startup; +// this is what lets watchsync.trakt.client_id stay out of the restart registry. +func TestSetClientIDAppliesToLaterRequests(t *testing.T) { + var gotAPIKey string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAPIKey = r.Header.Get("trakt-api-key") + writeJSON(t, w, []map[string]any{}) + })) + defer server.Close() + + client := NewClient("", 1000) + client.SetBaseURL(server.URL) + + if _, err := client.GetCollectionPreset(context.Background(), "popular", "movie", 1, ""); err == nil { + t.Fatal("expected an error while no client id is configured") + } + + client.SetClientID(" rotated-client-id ") + if got := client.ClientID(); got != "rotated-client-id" { + t.Fatalf("ClientID() = %q, want trimmed rotated-client-id", got) + } + if _, err := client.GetCollectionPreset(context.Background(), "popular", "movie", 1, ""); err != nil { + t.Fatalf("GetCollectionPreset after SetClientID: %v", err) + } + if gotAPIKey != "rotated-client-id" { + t.Fatalf("trakt-api-key = %q, want rotated-client-id", gotAPIKey) + } +} + func writeJSON(t *testing.T, w http.ResponseWriter, v any) { t.Helper() w.Header().Set("Content-Type", "application/json") diff --git a/internal/scanner/probe_repair.go b/internal/scanner/probe_repair.go index d2e9ec15b..ec727d8c3 100644 --- a/internal/scanner/probe_repair.go +++ b/internal/scanner/probe_repair.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "golang.org/x/sync/singleflight" @@ -130,9 +131,14 @@ type playbackProbeFileRepository interface { // PlaybackProbeEnsurer repairs missing playback-critical probe metadata on // demand by running a local ffprobe and persisting the result. type PlaybackProbeEnsurer struct { - fileRepo playbackProbeFileRepository + fileRepo playbackProbeFileRepository + // ffprobePath and ffmpegPath are the binaries captured when the ensurer was + // built. They are the fallback only: livePaths, once SetFFmpegPath has been + // called, supersedes them so a changed playback.ffmpeg_path reaches probe + // repair and the copy-safety scan without a server restart. ffprobePath string ffmpegPath string + livePaths atomic.Pointer[probeBinaries] timeout time.Duration // probeFile is the ffprobe entry point; nil means the package's ProbeFile. // Tests substitute it to drive the coalescing behavior deterministically. @@ -195,6 +201,42 @@ func NewPlaybackProbeEnsurer(fileRepo *FileRepository, ffprobePath, ffmpegPath s return e } +// probeBinaries is the pair of executables a probe run needs. They move +// together because both are derived from the single playback.ffmpeg_path +// setting. +type probeBinaries struct { + ffprobePath string + ffmpegPath string +} + +// SetFFmpegPath points probe repair and the copy-safety scan at a different +// FFmpeg install. Wiring it to the config watcher is what lets a changed +// playback.ffmpeg_path take effect without restarting the server. An in-flight +// probe keeps the binary it started with; the next one picks up the new path. +func (e *PlaybackProbeEnsurer) SetFFmpegPath(ffmpegPath string) { + if e == nil { + return + } + ffmpegPath = strings.TrimSpace(ffmpegPath) + e.livePaths.Store(&probeBinaries{ + ffprobePath: FFprobePathFromFFmpeg(ffmpegPath), + ffmpegPath: ffmpegPath, + }) +} + +// binaries returns the executables this probe run should use: the live +// configuration when one has been installed, otherwise the pair captured at +// construction. +func (e *PlaybackProbeEnsurer) binaries() probeBinaries { + if e == nil { + return probeBinaries{} + } + if live := e.livePaths.Load(); live != nil { + return *live + } + return probeBinaries{ffprobePath: e.ffprobePath, ffmpegPath: e.ffmpegPath} +} + // Ensure repairs playback-critical probe metadata and resolves the H.264 // copy-safety verdict. Use it where a play is being prepared — the planner // consumes the verdict to decide whether a video stream-copy is safe. @@ -250,7 +292,7 @@ func (e *PlaybackProbeEnsurer) EnsureCopySafetyCached(ctx context.Context, file // real work for this file: an H.264 video whose verdict is neither cached nor // persisted, on a server that has an ffmpeg to scan with. func (e *PlaybackProbeEnsurer) NeedsCopySafetyScan(file *models.MediaFile) bool { - if e == nil || strings.TrimSpace(e.ffmpegPath) == "" || !needsCopySafetyProbe(file) { + if e == nil || strings.TrimSpace(e.binaries().ffmpegPath) == "" || !needsCopySafetyProbe(file) { return false } _, known := e.knownCopySafetyVerdict(file) @@ -270,10 +312,11 @@ func (e *PlaybackProbeEnsurer) ScanCopySafety(ctx context.Context, file *models. if e == nil || file == nil { return false, false, nil } - if strings.TrimSpace(e.ffmpegPath) == "" { + ffmpegPath := strings.TrimSpace(e.binaries().ffmpegPath) + if ffmpegPath == "" { return false, false, errCopySafetyScanUnavailable } - return e.scanAndPersistCopySafety(ctx, file) + return e.scanAndPersistCopySafety(ctx, file, ffmpegPath) } // KnownCopySafetyVerdict answers the copy-safety question for a file without @@ -326,8 +369,11 @@ func (e *PlaybackProbeEnsurer) ensureProbeRepair(ctx context.Context, file *mode } current := file - if NeedsCriticalProbeRepair(file) && strings.TrimSpace(e.ffprobePath) != "" { - repaired, err := e.ensureCriticalProbe(ctx, file) + // One snapshot per repair: the guard and the ffprobe run must see the same + // binaries, or a SetFFmpegPath between them hands probeFile an empty path. + ffprobePath := strings.TrimSpace(e.binaries().ffprobePath) + if NeedsCriticalProbeRepair(file) && ffprobePath != "" { + repaired, err := e.ensureCriticalProbe(ctx, file, ffprobePath) if err != nil { return file, err } @@ -349,7 +395,7 @@ func (e *PlaybackProbeEnsurer) ensureProbeRepair(ctx context.Context, file *mode // cancellation while waiting. Inside the flight the row is re-read first, so a // caller holding a stale snapshot of an already-repaired file spawns no ffprobe // at all. -func (e *PlaybackProbeEnsurer) ensureCriticalProbe(ctx context.Context, file *models.MediaFile) (*models.MediaFile, error) { +func (e *PlaybackProbeEnsurer) ensureCriticalProbe(ctx context.Context, file *models.MediaFile, ffprobePath string) (*models.MediaFile, error) { sharedCtx := context.WithoutCancel(ctx) revisionKey := tonemap.RevisionForFile(file).Fingerprint() resultCh := e.probeRepair.DoChan(revisionKey, func() (any, error) { @@ -387,7 +433,7 @@ func (e *PlaybackProbeEnsurer) ensureCriticalProbe(ctx context.Context, file *mo if probeFile == nil { probeFile = ProbeFile } - probe, err := probeFile(probeCtx, e.ffprobePath, current.FilePath) + probe, err := probeFile(probeCtx, ffprobePath, current.FilePath) if err != nil || probe == nil { return nil, err } @@ -416,7 +462,8 @@ func (e *PlaybackProbeEnsurer) ensureCriticalProbe(ctx context.Context, file *mo // media_files row, and only then runs the bitstream scan — so a restart no // longer re-reads the opening seconds of every browsed H.264 file. func (e *PlaybackProbeEnsurer) ensureCopySafety(ctx context.Context, file *models.MediaFile) (*models.MediaFile, error) { - if !needsCopySafetyProbe(file) || strings.TrimSpace(e.ffmpegPath) == "" { + ffmpegPath := strings.TrimSpace(e.binaries().ffmpegPath) + if !needsCopySafetyProbe(file) || ffmpegPath == "" { return file, nil } @@ -425,7 +472,7 @@ func (e *PlaybackProbeEnsurer) ensureCopySafety(ctx context.Context, file *model return fileWithMultiplePPS(file, multi), nil } - multi, stale, err := e.scanAndPersistCopySafety(ctx, file) + multi, stale, err := e.scanAndPersistCopySafety(ctx, file, ffmpegPath) if err != nil { // Unknown safety must not fail open to the video-copy path this probe is // intended to guard. Leave MultiplePPS unset and do not cache or persist @@ -490,7 +537,7 @@ func copySafetyFlightKey(file *models.MediaFile) string { // A write refused as stale is neither memoized nor reported as a verdict: the // row has moved to a generation this scan never read, and both the memo and any // downstream notification would be facts about bytes nobody is serving. -func (e *PlaybackProbeEnsurer) scanAndPersistCopySafety(ctx context.Context, file *models.MediaFile) (bool, bool, error) { +func (e *PlaybackProbeEnsurer) scanAndPersistCopySafety(ctx context.Context, file *models.MediaFile, ffmpegPath string) (bool, bool, error) { fileID := file.ID filePath := file.FilePath fileSize := file.FileSize @@ -502,7 +549,7 @@ func (e *PlaybackProbeEnsurer) scanAndPersistCopySafety(ctx context.Context, fil timeout = 30 * time.Second } scanCtx, cancel := context.WithTimeout(ctx, timeout) - multi, err := DetectMultiplePPSH264(scanCtx, e.ffmpegPath, filePath) + multi, err := DetectMultiplePPSH264(scanCtx, ffmpegPath, filePath) cancel() if err != nil { return copySafetyOutcome{}, err diff --git a/internal/scanner/probe_repair_copy_safety_cached_test.go b/internal/scanner/probe_repair_copy_safety_cached_test.go index 5a90d9a2c..e1e568a2d 100644 --- a/internal/scanner/probe_repair_copy_safety_cached_test.go +++ b/internal/scanner/probe_repair_copy_safety_cached_test.go @@ -2,6 +2,7 @@ package scanner import ( "context" + "errors" "path/filepath" "testing" "time" @@ -213,3 +214,45 @@ func TestVideoCopySafetyUnknownIgnoresAudioOnlyFiles(t *testing.T) { t.Fatal("VideoCopySafetyUnknown() = true for an audio-only file, want false") } } + +// SetFFmpegPath is what makes playback.ffmpeg_path take effect without a +// server restart: the ensurer is built with the path captured at boot, and a +// later config change has to reach both the probe and the copy-safety scan. +func TestSetFFmpegPathOverridesBootPaths(t *testing.T) { + ensurer := NewPlaybackProbeEnsurer(nil, "boot-ffprobe", "boot-ffmpeg", time.Second) + + if got := ensurer.binaries(); got.ffmpegPath != "boot-ffmpeg" || got.ffprobePath != "boot-ffprobe" { + t.Fatalf("binaries() before reload = %+v, want the boot pair", got) + } + + ensurer.SetFFmpegPath("/opt/jellyfin-ffmpeg/ffmpeg") + + got := ensurer.binaries() + if got.ffmpegPath != "/opt/jellyfin-ffmpeg/ffmpeg" { + t.Fatalf("binaries().ffmpegPath = %q, want the reloaded path", got.ffmpegPath) + } + if want := filepath.Join("/opt/jellyfin-ffmpeg", "ffprobe"); got.ffprobePath != want { + t.Fatalf("binaries().ffprobePath = %q, want %q derived from the reloaded ffmpeg", got.ffprobePath, want) + } +} + +// A reload that blanks the FFmpeg path disables the copy-safety scan, the same +// as booting without one — the verdict stays unknown instead of being guessed. +func TestSetFFmpegPathEmptyDisablesCopySafetyScan(t *testing.T) { + ffmpegPath, _ := fakeFFmpeg(t, conflictingPPSAnnexB, 0) + ensurer := NewPlaybackProbeEnsurer(nil, "ffprobe", ffmpegPath, time.Second) + file := copySafetyTestFile(time.Date(2026, time.March, 4, 5, 6, 7, 0, time.UTC)) + + if !ensurer.NeedsCopySafetyScan(file) { + t.Fatal("NeedsCopySafetyScan() = false with a configured ffmpeg, want true") + } + + ensurer.SetFFmpegPath("") + + if ensurer.NeedsCopySafetyScan(file) { + t.Fatal("NeedsCopySafetyScan() = true after the path was cleared, want false") + } + if _, _, err := ensurer.ScanCopySafety(context.Background(), file); !errors.Is(err, errCopySafetyScanUnavailable) { + t.Fatalf("ScanCopySafety() error = %v, want errCopySafetyScanUnavailable", err) + } +} diff --git a/internal/settingskeys/keys.go b/internal/settingskeys/keys.go index 670049707..f21ad8d5f 100644 --- a/internal/settingskeys/keys.go +++ b/internal/settingskeys/keys.go @@ -9,7 +9,7 @@ package settingskeys // Revision is the manifest revision these bindings were generated from. -const Revision = 7 +const Revision = 8 // Setting keys, one constant per definition. const ( @@ -91,8 +91,14 @@ const ( SubtitleMatchesDevice = "subtitle.matches_device" // Poster badges UiCardOverlays = "ui.card_overlays" + // Card overlays enabled + UiCardOverlaysEnabled = "ui.card_overlays_enabled" // Media cards UiCardPresentation = "ui.card_presentation" + // Card quick actions + UiCardQuickActions = "ui.card_quick_actions" + // Card quick actions enabled + UiCardQuickActionsEnabled = "ui.card_quick_actions_enabled" // Custom CSS UiCustomCss = "ui.custom_css" // Custom theme variables @@ -156,7 +162,10 @@ var Remote = []string{ PlayerVideoGravity, SearchMediaScope, UiCardOverlays, + UiCardOverlaysEnabled, UiCardPresentation, + UiCardQuickActions, + UiCardQuickActionsEnabled, UiCustomCss, UiCustomThemeVars, UiDateFormat, diff --git a/web/src/App.tsx b/web/src/App.tsx index 3ea930534..dcb262c4e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,7 +7,9 @@ import { type ReactNode, } from "react"; import { - BrowserRouter, + createBrowserRouter, + createRoutesFromElements, + Outlet, Routes, Route, Navigate, @@ -16,6 +18,10 @@ import { useParams, useSearchParams, } from "react-router"; +// The DOM build of the provider is the same component with `ReactDOM.flushSync` +// wired in, which is what lets a view-transition navigation apply its state +// inside `startViewTransition`. Links across the app opt into view transitions. +import { RouterProvider } from "react-router/dom"; import { QueryClientProvider, useQueryClient } from "@tanstack/react-query"; import { queryClient } from "@/lib/query-client"; import { AuthProvider, useAuth } from "@/hooks/useAuth"; @@ -59,20 +65,33 @@ import { buildQueryCatalogHref, buildUserCollectionCatalogHref, } from "@/pages/catalogSearchParams"; +import { buildLegacyAutoscanRedirectTarget } from "@/pages/autoscanSearchParams"; import { buildLegacyWebhookSyncRedirectTarget } from "@/lib/webhookSync"; import { toast } from "sonner"; import { prewarmCodecDetection } from "@/player/hooks/useCodecDetection"; +import { + prefetchRouteChunks, + type RouteChunkImport, +} from "@/lib/routeChunkPrefetch"; + +// Hot routes keep their import factory in a named binding so the idle warm-up +// below can pull the chunk before the user navigates. See HOT_ROUTE_CHUNKS. +const importLibraryPage = () => import("@/pages/LibraryPage"); +const importItemDetail = () => import("@/pages/ItemDetail/index"); +const importPersonDetail = () => import("@/pages/PersonDetail"); +const importCollections = () => import("@/pages/Collections"); +const importRecommendations = () => import("@/pages/Recommendations"); const AdminLayout = lazy(() => import("@/components/AdminLayout")); const OAuthComplete = lazy(() => import("@/pages/OAuthComplete")); const ActivateDevice = lazy(() => import("@/pages/ActivateDevice")); const SetupWizard = lazy(() => import("@/pages/SetupWizard")); const Profiles = lazy(() => import("@/pages/Profiles")); -const LibraryPage = lazy(() => import("@/pages/LibraryPage")); -const ItemDetail = lazy(() => import("@/pages/ItemDetail/index")); +const LibraryPage = lazy(importLibraryPage); +const ItemDetail = lazy(importItemDetail); const EbookReader = lazy(() => import("@/pages/EbookReader")); -const PersonDetail = lazy(() => import("@/pages/PersonDetail")); -const Collections = lazy(() => import("@/pages/Collections")); +const PersonDetail = lazy(importPersonDetail); +const Collections = lazy(importCollections); const CollectionEditor = lazy(() => import("@/pages/CollectionEditor")); const Notifications = lazy(() => import("@/pages/Notifications")); const DeviceSettings = lazy(() => import("@/pages/settings/DeviceSettings")); @@ -89,7 +108,6 @@ const AdminDiagnostics = lazy(() => import("@/pages/AdminDiagnostics")); const AdminAccessGroups = lazy(() => import("@/pages/AdminAccessGroups")); const AdminUsers = lazy(() => import("@/pages/AdminUsers")); const AdminRequests = lazy(() => import("@/pages/AdminRequests")); -const AdminAutoscan = lazy(() => import("@/pages/AdminAutoscan")); const AdminDevices = lazy(() => import("@/pages/AdminDevices")); const AdminLibraries = lazy(() => import("@/pages/AdminLibraries")); const AdminSettingsLayout = lazy( @@ -115,7 +133,7 @@ const AdminRecommendations = lazy(() => import("@/pages/AdminRecommendations")); const AdminPolicyLayout = lazy( () => import("@/pages/admin-policy/AdminPolicyLayout"), ); -const Recommendations = lazy(() => import("@/pages/Recommendations")); +const Recommendations = lazy(importRecommendations); const RecommendationsSection = lazy( () => import("@/pages/RecommendationsSection"), ); @@ -171,7 +189,26 @@ const WatchTogetherRoomPage = lazy( const WatchRoute = lazy(() => import("@/pages/WatchRoute")); const ProfileCustomizeHome = lazy(() => import("@/pages/ProfileCustomizeHome")); -/** Scrolls to top on pathname change (custom replacement for ScrollRestoration which requires data router). */ +/** + * Routes a browsing session reaches within the first few interactions. Home + * links straight into item details, the sidebar into libraries, and item pages + * into people and recommendations, so paying their chunk cost while the app is + * idle is cheaper than paying it inside a navigation. + */ +const HOT_ROUTE_CHUNKS: readonly RouteChunkImport[] = [ + importItemDetail, + importLibraryPage, + importPersonDetail, + importRecommendations, + importCollections, +]; + +/** + * Scrolls to top on pathname change. Kept in place of react-router's + * ``, which the data router would now allow: that one + * restores the previous offset on back/forward, while every page here expects + * to open at the top. + */ function useScrollRestoration() { const { pathname } = useLocation(); useEffect(() => { @@ -415,6 +452,16 @@ function LegacyWebhookSyncRedirect() { return ; } +/** + * `/admin/autoscan` → the Autoscan tab on Libraries. The old query has to be + * translated, not dropped: the panel's own view moved from `tab` to `view` + * because `tab` now names the Libraries tab hosting it. + */ +function LegacyAutoscanRedirect() { + const { search } = useLocation(); + return ; +} + function LegacyPersonalCatalogRedirect({ source, }: { @@ -524,7 +571,8 @@ function AppRoutes() { element={} /> } /> - } /> + {/* Autoscan is a tab on Libraries now; keep old links working. */} + } /> } /> } /> } /> } /> - } /> + } /> } /> { + // Nothing behind these routes is reachable while signed out, and the login + // screen is exactly where bandwidth should stay free for the first paint. + if (!isAuthenticated) return; + return prefetchRouteChunks(HOT_ROUTE_CHUNKS); + }, [isAuthenticated]); + return null; +} + +/** + * Everything that used to sit directly inside ``: providers, + * app-wide chrome, and the routed page tree behind one Suspense boundary. + * + * `RouterProvider` takes no children, so this is the data router's single root + * layout route. Nothing was hoisted above the router: ErrorBoundary, + * RealtimeEventsProvider and WatchPlaybackProvider all read the location or + * navigate, and the providers that need nothing from the router sit above those + * in the chain — so splitting the stack would reorder providers for no gain. + * The element below is created once at module scope, so React skips + * re-rendering this subtree on navigation exactly as it did when the tree hung + * off ``. + */ +function AppShell() { return ( - - - - - - - - - - - - - - - - - - }> - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + }> + + + + + + + + + + + + + + + ); } + +/** + * A data router is what makes navigation blockable (`useBlocker`, used by + * `UnsavedChangesGuard` to protect staged settings edits) — that is the whole + * reason for `createBrowserRouter` here. The route tree itself stays + * declarative below the root: the splat child hands off to ``, whose + * descendant routes match from `/` because a splat contributes nothing to the + * pathname base. + */ +const appRoutes = createRoutesFromElements( + }> + } /> + , +); + +export default function App() { + // Per-App-instance rather than a module-scope singleton: a router captures + // the current history the moment it is built, and tests render App more than + // once against different entries. + const [router] = useState(() => createBrowserRouter(appRoutes)); + + return ; +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 743ef7370..409b1f583 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -4621,6 +4621,12 @@ export interface RateLimitConfig { active?: boolean; /** Backend the running limiter uses; may differ from `backend` until restart. */ active_backend?: string; + /** + * Whether the Redis backend can be selected at all (GET responses only). + * Sentinel and REDIS_URL deployments have no stored `redis.url`, so only the + * server can answer this. + */ + redis_available?: boolean; } export interface RateLimitUpdateResponse { @@ -4650,6 +4656,14 @@ export interface AdminServerStatus { restart_required: boolean; restart_required_at?: string; restart_required_reason?: string; + /** + * Every distinct reason marked since boot ("setting:" for settings + * saves), so pending restarts can be scoped per subsystem. The singular + * field only remembers the last save. + */ + restart_required_reasons?: string[]; + /** Increments on every restart-required save; re-arms the dismissed banner. */ + restart_mark_count?: number; restart_requested: boolean; restart_requested_at?: string; } diff --git a/web/src/app.css b/web/src/app.css index bf3c2ffd0..3c31df2ff 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -734,6 +734,7 @@ } html[data-high-contrast="true"] .surface-panel-subtle, html[data-high-contrast="true"] .surface-panel, + html[data-high-contrast="true"] .surface-panel-raised, html[data-high-contrast="true"] .glass, html[data-high-contrast="true"] .glass-subtle { border-color: color-mix(in srgb, var(--border) 88%, white 12%); @@ -1425,6 +1426,17 @@ transform: translateY(-4px); } + /* Home/library pages can contain hundreds of cards across off-screen rows. + Let supporting engines skip layout and paint for those rows until they + approach the viewport. Browsers without content-visibility ignore this + and keep the existing rendering path. MediaCarousel sets the placeholder + height from carouselIntrinsicHeight(), which owns the per-card-size + values alongside the card widths they derive from. */ + .media-carousel { + content-visibility: auto; + contain-intrinsic-size: auto var(--carousel-intrinsic-h, 23rem); + } + /* Card actions stay hidden at rest so touch devices get clean artwork; a long press opens the action sheet instead. Keyboard focus and an open menu reveal them on every device so keyboard and assistive-tech users @@ -1444,7 +1456,13 @@ phone cannot uncover the controls. This deliberately uses a direct :hover selector: Tailwind's group-hover variant is limited to the primary pointer's (hover: hover) result, which can exclude Windows hybrid devices - even while an attached mouse is actively hovering the card. */ + even while an attached mouse is actively hovering the card. + + Card actions sit at z-20 in the card wrapper's stacking context while the + overlay-badge layer sits at z-10 inside the artwork box, so a visible + action always covers the badges in that corner. Badges anchor flush in the + corners and never reserve room for the actions; the overlay layer is + pointer-events-none, so covered badges cannot steal an action's clicks. */ @media (any-hover: hover) and (any-pointer: fine) { .group\/media:hover .media-card-hover-dim { background-color: rgb(0 0 0 / 0.3); @@ -1955,6 +1973,17 @@ box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04); } + /* One surface step above a surface-panel shell. Settings groups sit on + this so they read as defined panels inside the shell — in light themes + the shell and --surface are near-identical, so the raised fill plus the + hairline are what keep group boundaries visible. */ + .surface-panel-raised { + background: color-mix(in srgb, var(--surface-raised) 65%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + border-radius: 1.7rem; + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04); + } + /* Panel chrome only once there is a sidebar to enclose. Below this the settings detail view is already a stack of surface-panel groups, and a panel around them would nest a card in a card. */ diff --git a/web/src/components/AdminLayout.test.tsx b/web/src/components/AdminLayout.test.tsx new file mode 100644 index 000000000..bdcf6f2bb --- /dev/null +++ b/web/src/components/AdminLayout.test.tsx @@ -0,0 +1,150 @@ +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createMemoryRouter, RouterProvider } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + useAdminServerStatus: vi.fn(), +})); + +vi.mock("@/hooks/queries/admin/settings", () => ({ + useAdminServerStatus: () => mocks.useAdminServerStatus(), +})); +vi.mock("@/hooks/queries/admin/plugins", () => ({ + useAdminPluginInstallations: () => ({ data: undefined }), +})); +vi.mock("@/hooks/queries/admin/policy", () => ({ + usePolicyCapability: () => ({ data: undefined }), +})); +vi.mock("@/components/AdminSidebar", () => ({ default: () => null })); +vi.mock("@/components/AdminSectionCommandDialog", () => ({ + AdminSectionCommandDialog: () => null, +})); +vi.mock("@/components/ServerActivity", () => ({ default: () => null })); +vi.mock("@/playback/watchPlaybackContext", () => ({ + useWatchPlaybackController: () => ({ isBackgroundBarVisible: false }), +})); +vi.mock("@/pages/audiobooks/player/audiobookPlaybackContext", () => ({ + useAudiobookPlaybackController: () => null, +})); + +import AdminLayout from "./AdminLayout"; + +// The dashboard and the users page stand in for "any admin page that is not +// settings" — the shell is the only thing that renders the restart prompt, so +// both must show it. +function renderAdmin(initialPath = "/admin") { + const router = createMemoryRouter( + [ + { + path: "/admin", + element: , + children: [ + { index: true, element:

Admin dashboard

}, + { path: "users", element:

Admin users

}, + ], + }, + ], + { initialEntries: [initialPath] }, + ); + + return { router, ...render() }; +} + +beforeEach(() => { + mocks.useAdminServerStatus.mockReturnValue({ + data: { restart_required: true }, + }); + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query === "(min-width: 64rem)", + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("AdminLayout search shortcut hint", () => { + function stubUserAgent(value: string) { + vi.spyOn(window.navigator, "userAgent", "get").mockReturnValue(value); + } + + // The dialog opens on Cmd or Ctrl, so the advertised hint has to name the key + // this keyboard actually has — a hardcoded ⌘ is a dead instruction on Windows + // and Linux, which is most self-hosters. + it("names Ctrl off Apple platforms", () => { + stubUserAgent( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + ); + renderAdmin(); + + const [search] = screen.getAllByRole("button", { + name: "Search admin sections", + }); + expect(search).toHaveAttribute("title", "Search admin sections (Ctrl K)"); + expect(screen.getByText("Ctrl K")).toBeInTheDocument(); + expect(screen.queryByText(/⌘/)).not.toBeInTheDocument(); + }); + + it("names the command glyph on Apple platforms", () => { + stubUserAgent( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15", + ); + renderAdmin(); + + const [search] = screen.getAllByRole("button", { + name: "Search admin sections", + }); + expect(search).toHaveAttribute("title", "Search admin sections (⌘ K)"); + expect(screen.getByText("⌘ K")).toBeInTheDocument(); + }); +}); + +describe("AdminLayout restart banner", () => { + it("stays quiet while no restart is owed", () => { + mocks.useAdminServerStatus.mockReturnValue({ + data: { restart_required: false }, + }); + renderAdmin(); + + expect( + screen.getByRole("heading", { name: "Admin dashboard" }), + ).toBeInTheDocument(); + expect(screen.queryByText("Restart required")).not.toBeInTheDocument(); + }); + + it("prompts on a page outside settings, above the routed page", () => { + renderAdmin("/admin/users"); + + const banner = screen.getByRole("status"); + const page = screen.getByRole("heading", { name: "Admin users" }); + + expect(banner).toHaveTextContent("Restart required"); + // Node.DOCUMENT_POSITION_FOLLOWING: the page comes after the banner. + expect( + banner.compareDocumentPosition(page) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it("keeps a dismissal across admin navigation", async () => { + const { router } = renderAdmin(); + + await userEvent.click(screen.getByRole("button", { name: "Later" })); + expect(screen.queryByText("Restart required")).not.toBeInTheDocument(); + + // The shell owns the banner, so moving between admin pages neither + // resurrects the prompt nor loses the admin's "Later". + await act(async () => { + await router.navigate("/admin/users"); + }); + + expect( + screen.getByRole("heading", { name: "Admin users" }), + ).toBeInTheDocument(); + expect(screen.queryByText("Restart required")).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/AdminLayout.tsx b/web/src/components/AdminLayout.tsx index c8f992978..7b5c868f7 100644 --- a/web/src/components/AdminLayout.tsx +++ b/web/src/components/AdminLayout.tsx @@ -1,7 +1,9 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Outlet, useLocation } from "react-router"; import AdminSidebar from "@/components/AdminSidebar"; +import { AdminSectionCommandDialog } from "@/components/AdminSectionCommandDialog"; import ServerActivity from "@/components/ServerActivity"; +import { RestartBanner } from "@/components/admin/RestartBanner"; import { Sheet, SheetClose, @@ -11,8 +13,14 @@ import { SheetTrigger, } from "@/components/ui/sheet"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; +import { useAdminPluginInstallations } from "@/hooks/queries/admin/plugins"; +import { usePolicyCapability } from "@/hooks/queries/admin/policy"; +import { useAdminServerStatus } from "@/hooks/queries/admin/settings"; +import { buildAdminCommandNavSections } from "@/lib/adminNavigation"; import { resolveAdminDocumentTitle } from "@/lib/documentTitle"; -import { Menu, X } from "lucide-react"; +import { searchShortcutLabel } from "@/lib/keyboardShortcut"; +import { cn } from "@/lib/utils"; +import { Menu, Search, X } from "lucide-react"; import { useWatchPlaybackController } from "@/playback/watchPlaybackContext"; import { useAudiobookPlaybackController } from "@/pages/audiobooks/player/audiobookPlaybackContext"; @@ -20,7 +28,25 @@ const ADMIN_DESKTOP_MEDIA_QUERY = "(min-width: 64rem)"; export default function AdminLayout() { const [mobileOpen, setMobileOpen] = useState(false); + const [commandOpen, setCommandOpen] = useState(false); const location = useLocation(); + const { data: adminInstallations } = useAdminPluginInstallations(); + const policyCapability = usePolicyCapability(); + // The one restart prompt for the admin area. Read here, not in a page: a + // restart is owed by the server, not by the page that happened to ask for + // it, so the prompt has to survive navigating away from settings. The query + // is shared and cached, so the settings overview reading the same status + // costs nothing extra. + const { data: serverStatus } = useAdminServerStatus(); + // Mounted here rather than on the dashboard so Cmd+K reaches every admin + // page, which is what the pages that advertise the shortcut assume. + const adminSearchSections = useMemo( + () => + buildAdminCommandNavSections(adminInstallations, { + policyEditorAvailable: policyCapability.data?.editor_available === true, + }), + [adminInstallations, policyCapability.data?.editor_available], + ); const { isBackgroundBarVisible } = useWatchPlaybackController(); const audiobookPlayback = useAudiobookPlaybackController(); const hasBackgroundBar = @@ -48,6 +74,11 @@ export default function AdminLayout() { return (
+
- +
+ setCommandOpen(true)} + className="h-11 w-11" + /> + +
{/* Mobile sidebar drawer */} @@ -119,8 +156,9 @@ export default function AdminLayout() { - {/* Desktop activity indicator */} -
+ {/* Desktop header controls */} +
+ setCommandOpen(true)} showShortcut />
@@ -132,9 +170,58 @@ export default function AdminLayout() { }`} >
+ {/* Above the routed page and inside the content column, so every + admin page carries the prompt and none of them can be covered by + it. `lg:mt-7` clears the fixed Search/activity controls in the + top-right corner (top-5, h-9 → they end 3.5rem down), which would + otherwise float over the banner's buttons. */} +
); } + +function AdminSearchButton({ + onClick, + className, + showShortcut = false, +}: { + onClick: () => void; + className?: string; + showShortcut?: boolean; +}) { + // Advertised, not hardcoded: the dialog opens on either modifier, so the hint + // has to name the one this keyboard actually has. + const shortcut = searchShortcutLabel(); + + return ( + + ); +} diff --git a/web/src/components/AdminSectionCommandDialog.test.tsx b/web/src/components/AdminSectionCommandDialog.test.tsx index 599ee0be2..efc86b6b9 100644 --- a/web/src/components/AdminSectionCommandDialog.test.tsx +++ b/web/src/components/AdminSectionCommandDialog.test.tsx @@ -79,21 +79,25 @@ describe("AdminSectionCommandDialog", () => { ).not.toBeInTheDocument(); }); - it("searches individual admin setting labels from the dashboard dialog", async () => { + it("searches individual admin setting labels from the admin dialog", async () => { renderDialog(); const searchBox = await openDialog(); - await userEvent.type(searchBox, "pool max open"); + await userEvent.type(searchBox, "maximum postgres connections"); expect( - screen.getByRole("option", { name: /Database/ }), + screen.getByRole("option", { name: /Storage & Database/ }), + ).toBeInTheDocument(); + expect( + screen.getByText("Maximum Postgres connections"), ).toBeInTheDocument(); - expect(screen.getByText("Pool Max Open")).toBeInTheDocument(); - await userEvent.click(screen.getByRole("option", { name: /Database/ })); + await userEvent.click( + screen.getByRole("option", { name: /Storage & Database/ }), + ); expect(screen.getByLabelText("Current path")).toHaveTextContent( - "/admin/settings?tab=database", + "/admin/settings/infrastructure", ); }); @@ -136,7 +140,9 @@ describe("AdminSectionCommandDialog", () => { const searchBox = await openDialog(); await userEvent.type(searchBox, "logs"); - await userEvent.click(screen.getByRole("option", { name: /Logs/ })); + await userEvent.click( + screen.getByRole("option", { name: /^LogsServer log stream/ }), + ); expect(screen.getByLabelText("Current path")).toHaveTextContent( "/admin/logs", diff --git a/web/src/components/AdminSectionCommandDialog.tsx b/web/src/components/AdminSectionCommandDialog.tsx index 70d43444e..7f4d6381a 100644 --- a/web/src/components/AdminSectionCommandDialog.tsx +++ b/web/src/components/AdminSectionCommandDialog.tsx @@ -20,12 +20,26 @@ import { cn } from "@/lib/utils"; interface AdminSectionCommandDialogProps { sections: readonly AdminNavGroup[]; + /** Controlled open state, so a visible search button can open the palette. */ + open?: boolean; + onOpenChange?: (open: boolean) => void; } export function AdminSectionCommandDialog({ sections, + open: openProp, + onOpenChange, }: AdminSectionCommandDialogProps) { - const [open, setOpen] = useState(false); + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const open = openProp ?? uncontrolledOpen; + const isControlled = openProp !== undefined; + const setOpen = useCallback( + (next: boolean) => { + if (!isControlled) setUncontrolledOpen(next); + onOpenChange?.(next); + }, + [isControlled, onOpenChange], + ); const [query, setQuery] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef(null); @@ -64,12 +78,12 @@ export function AdminSectionCommandDialog({ setOpen(false); setQuery(""); setSelectedIndex(0); - }, []); + }, [setOpen]); const openDialog = useCallback(() => { setOpen(true); focusSearch(); - }, [focusSearch]); + }, [focusSearch, setOpen]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -94,7 +108,7 @@ export function AdminSectionCommandDialog({ void navigateToPluginRoute(item.href); return; } - void navigate(item.href); + navigate(item.href); }, [closeDialog, navigate], ); diff --git a/web/src/components/AdminSidebar.test.tsx b/web/src/components/AdminSidebar.test.tsx index ce3826057..ab92f405e 100644 --- a/web/src/components/AdminSidebar.test.tsx +++ b/web/src/components/AdminSidebar.test.tsx @@ -87,12 +87,21 @@ describe("AdminSidebar", () => { "Content", "Automation", "Users", + "Settings", "System", ]) { expect(markup).toContain(`>${section}<`); } }); + it("keeps settings as one sidebar destination", () => { + const markup = renderSidebar(); + const settingsLinks = markup.match(/href="\/admin\/settings[^"]*"/g) ?? []; + + expect(settingsLinks).toEqual(['href="/admin/settings"']); + expect(markup).not.toContain("/admin/settings?tab="); + }); + it("renders as an embedded rail inside the mobile drawer", () => { const markup = renderSidebar(true); diff --git a/web/src/components/AppSidebar.test.tsx b/web/src/components/AppSidebar.test.tsx index 2ef45a33d..f97a5e665 100644 --- a/web/src/components/AppSidebar.test.tsx +++ b/web/src/components/AppSidebar.test.tsx @@ -151,15 +151,12 @@ vi.mock("@/hooks/useTheme", () => ({ previewTheme: vi.fn(), resetPreviewTheme: vi.fn(), }), + isKeyboardFocus: () => false, // PrairieBrand reads the appearance through the optional hook; null keeps it on // the dark built-in assets, matching the sidebar's own surface. useOptionalTheme: () => null, })); -vi.mock("@/components/ThemeSwitcher", () => ({ - default: () =>
Theme switcher
, -})); - vi.mock("@/components/ui/avatar", () => ({ Avatar: ({ children }: { children: ReactNode }) =>
{children}
, AvatarFallback: ({ children }: { children: ReactNode }) => ( diff --git a/web/src/components/AppSidebar.tsx b/web/src/components/AppSidebar.tsx index d66ab7270..b174309d7 100644 --- a/web/src/components/AppSidebar.tsx +++ b/web/src/components/AppSidebar.tsx @@ -71,7 +71,7 @@ import { Send, Bell, } from "lucide-react"; -import { useTheme } from "@/hooks/useTheme"; +import { isKeyboardFocus, useTheme } from "@/hooks/useTheme"; import { CURATED_THEME_IDS, THEMES } from "@/lib/themes"; import { cn } from "@/lib/utils"; import { useUICustomization } from "@/hooks/useUICustomization"; @@ -1169,7 +1169,13 @@ export default function AppSidebar({ }} onMouseEnter={() => previewTheme(id)} onMouseLeave={resetPreviewTheme} - onFocus={() => previewTheme(id)} + // Radix focuses whichever item the pointer is over, so + // an unconditional focus preview would bypass the hover + // intent delay. Keyboard focus only. + onFocus={(event) => { + if (isKeyboardFocus(event.currentTarget)) + previewTheme(id); + }} onBlur={resetPreviewTheme} aria-label={def.label} title={def.label} diff --git a/web/src/components/CollectionTemplateGallery/MDBListBrowser.tsx b/web/src/components/CollectionTemplateGallery/MDBListBrowser.tsx index 9eb223aa2..44b0a37e3 100644 --- a/web/src/components/CollectionTemplateGallery/MDBListBrowser.tsx +++ b/web/src/components/CollectionTemplateGallery/MDBListBrowser.tsx @@ -10,7 +10,6 @@ import { useMDBListTop, } from "@/hooks/queries/userCollectionImports"; -import { Search } from "lucide-react"; interface Props { onPick: (list: MDBListListSummary, jsonURL: string) => void; } @@ -33,8 +32,10 @@ export function MDBListBrowser({ onPick }: Props) {

MDBList list search isn’t available — an admin needs to add an MDBList API key under{" "} - Settings → Integrations. You can - still paste a list URL below. + + Settings → Subtitles & Metadata + + . You can still paste a list URL below.

); @@ -66,7 +67,6 @@ export function MDBListBrowser({ onPick }: Props) { setShowTop(true); }} > - Top lists diff --git a/web/src/components/ContinueWatchingCard.restart.test.tsx b/web/src/components/ContinueWatchingCard.restart.test.tsx index 39cc83bc3..b4637f3de 100644 --- a/web/src/components/ContinueWatchingCard.restart.test.tsx +++ b/web/src/components/ContinueWatchingCard.restart.test.tsx @@ -51,6 +51,7 @@ describe("ContinueWatchingCard restart eligibility", () => { backdrop_thumbhash: "", logo_url: "", }} + quickActionMode="both" /> , diff --git a/web/src/components/ContinueWatchingCard.test.tsx b/web/src/components/ContinueWatchingCard.test.tsx index 6350df146..aa9b674f3 100644 --- a/web/src/components/ContinueWatchingCard.test.tsx +++ b/web/src/components/ContinueWatchingCard.test.tsx @@ -62,6 +62,7 @@ describe("ContinueWatchingCard", () => { backdrop_thumbhash: "", logo_url: "", }} + quickActionMode="both" /> , @@ -105,6 +106,7 @@ describe("ContinueWatchingCard", () => { in_watchlist: false, }, }} + quickActionMode="both" /> , @@ -142,6 +144,7 @@ describe("ContinueWatchingCard", () => { backdrop_thumbhash: "", logo_url: "", }} + quickActionMode="both" /> , @@ -181,6 +184,7 @@ describe("ContinueWatchingCard", () => { in_watchlist: false, }, }} + quickActionMode="both" /> , @@ -217,6 +221,7 @@ describe("ContinueWatchingCard", () => { logo_url: "", }} libraryId={12} + quickActionMode="both" /> , @@ -287,6 +292,7 @@ describe("ContinueWatchingCard", () => { completed: false, updated_at: "2026-03-07T00:00:00Z", }} + quickActionMode="both" /> , @@ -330,6 +336,7 @@ describe("ContinueWatchingCard", () => { backdrop_thumbhash: "", logo_url: "", }} + quickActionMode="both" /> , @@ -371,6 +378,7 @@ describe("ContinueWatchingCard", () => { logo_url: "", }} libraryId={7} + quickActionMode="both" /> , diff --git a/web/src/components/ContinueWatchingCard.tsx b/web/src/components/ContinueWatchingCard.tsx index 27deed1aa..9392e50c5 100644 --- a/web/src/components/ContinueWatchingCard.tsx +++ b/web/src/components/ContinueWatchingCard.tsx @@ -27,6 +27,7 @@ import { import { useUICustomization } from "@/hooks/useUICustomization"; import { carouselCardWidthClasses } from "@/lib/uiCustomization"; import CardPlayOverlay from "@/components/CardPlayOverlay"; +import type { CardQuickActionMode } from "@/lib/cardQuickActions"; type ContinueWatchingCardProps = ( | { @@ -41,6 +42,7 @@ type ContinueWatchingCardProps = ( } ) & { overlayPrefs?: CardOverlayPrefs | null; + quickActionMode?: CardQuickActionMode; libraryId?: number; variant?: "wide" | "poster"; }; @@ -137,6 +139,8 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) { ? (card.positionSeconds / card.durationSeconds) * 100 : 0; const hasPartialProgress = progressPercent > 0 && progressPercent < 100; + // Drives both the bar itself and the overlay row's clearance above it. + const showProgressBar = !isNextUp && progressPercent > 0; const hasEpisodeMeta = card.seasonNumber != null && card.episodeNumber != null; // A manga chapter is an ebook item that carries its owning series; the card @@ -310,6 +314,7 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) { data={overlayDataFromSectionItem(props.sectionItem)} prefs={props.overlayPrefs} variant={variant} + hasProgressBar={showProgressBar} /> )} @@ -318,7 +323,7 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) { {/* Progress bar — inset pill so a full bar doesn't read as a stray edge along the artwork */} - {!isNextUp && progressPercent > 0 && ( + {showProgressBar && (
diff --git a/web/src/components/EpisodeRow.tsx b/web/src/components/EpisodeRow.tsx index b7ef4e9b4..f74126239 100644 --- a/web/src/components/EpisodeRow.tsx +++ b/web/src/components/EpisodeRow.tsx @@ -74,6 +74,7 @@ export default function EpisodeRow({ data={overlayDataFromEpisodeListItem(episode)} prefs={overlayPrefs} variant="wide" + hasProgressBar={hasProgress} /> )} {hasProgress && ( diff --git a/web/src/components/ItemCard.test.tsx b/web/src/components/ItemCard.test.tsx index f18a7a365..b4fdc84f1 100644 --- a/web/src/components/ItemCard.test.tsx +++ b/web/src/components/ItemCard.test.tsx @@ -106,6 +106,17 @@ describe("ItemCard SortMeta", () => { ); }); + it("passes the resolved profile quick-action mode to the menu", () => { + renderCard({ + item: { ...baseItem, content_id: "movie-1", type: "movie" }, + quickActionMode: "favorites", + }); + + expect(mocks.mediaItemMenu).toHaveBeenCalledWith( + expect.objectContaining({ quickActionMode: "favorites" }), + ); + }); + it("renders the series last air date when sorted by last_air_date", () => { const markup = renderCard({ sortField: "last_air_date", diff --git a/web/src/components/ItemCard.tsx b/web/src/components/ItemCard.tsx index c0c42683c..92a598c26 100644 --- a/web/src/components/ItemCard.tsx +++ b/web/src/components/ItemCard.tsx @@ -19,6 +19,7 @@ import { formatBitrate } from "@/lib/mediaFormat"; import { useUICustomization } from "@/hooks/useUICustomization"; import { buildItemHref } from "@/lib/mediaNavigation"; import CardPlayOverlay from "@/components/CardPlayOverlay"; +import type { CardQuickActionMode } from "@/lib/cardQuickActions"; const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; @@ -208,6 +209,7 @@ export default function ItemCard({ libraryId, sortField, overlayPrefs, + quickActionMode = "none", narrowPosterActions = false, selectionMode = false, selected = false, @@ -217,6 +219,7 @@ export default function ItemCard({ libraryId?: number; sortField?: string; overlayPrefs?: CardOverlayPrefs | null; + quickActionMode?: CardQuickActionMode; narrowPosterActions?: boolean; selectionMode?: boolean; selected?: boolean; @@ -373,6 +376,7 @@ export default function ItemCard({ userState={item.user_state} variant="poster" narrowPosterActions={narrowPosterActions} + quickActionMode={quickActionMode} longPressRef={cardRef} itemTitle={displayTitle} /> diff --git a/web/src/components/ItemGrid.tsx b/web/src/components/ItemGrid.tsx index 4346bd170..c455ef35c 100644 --- a/web/src/components/ItemGrid.tsx +++ b/web/src/components/ItemGrid.tsx @@ -50,7 +50,7 @@ export default function ItemGrid(props: ItemGridProps) { selectedIds, onToggleSelect, } = props; - const { prefs: overlayPrefs } = useOverlayPrefs(); + const { prefs: overlayPrefs, quickActionMode } = useOverlayPrefs(); const { cardPresentation } = useUICustomization(); const gridGap = cardPresentation.poster_size === "large" ? 16 : 12; const gridClasses = cardGridClasses(cardPresentation.poster_size); @@ -180,6 +180,7 @@ export default function ItemGrid(props: ItemGridProps) { libraryId={libraryId} sortField={sortField} overlayPrefs={overlayPrefs} + quickActionMode={quickActionMode} narrowPosterActions={narrowPosterActions} selectionMode={selectionMode} selected={selectedIds?.has(item.content_id) ?? false} diff --git a/web/src/components/Layout.test.tsx b/web/src/components/Layout.test.tsx index 6fa31793a..319158c34 100644 --- a/web/src/components/Layout.test.tsx +++ b/web/src/components/Layout.test.tsx @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ location: { pathname: "/", search: "", key: "home" }, navigate: vi.fn(), prefetchQuery: vi.fn(), + getQueryData: vi.fn(), renderSurface: true, beginResult: undefined as boolean | undefined, profile: { @@ -33,7 +34,10 @@ vi.mock("@tanstack/react-query", async () => { ); return { ...actual, - useQueryClient: () => ({ prefetchQuery: mocks.prefetchQuery }), + useQueryClient: () => ({ + prefetchQuery: mocks.prefetchQuery, + getQueryData: mocks.getQueryData, + }), }; }); @@ -145,6 +149,7 @@ beforeEach(() => { mocks.location = { pathname: "/", search: "", key: "home" }; mocks.navigate.mockReset(); mocks.prefetchQuery.mockReset(); + mocks.getQueryData.mockReset(); mocks.renderSurface = true; mocks.beginResult = undefined; mocks.profile = { @@ -276,6 +281,32 @@ describe("Layout detail reveal", () => { ).toHaveTextContent("true"); }); + it("reveals immediately when the item detail is already cached", () => { + mocks.getQueryData.mockImplementation((queryKey: unknown) => + JSON.stringify(queryKey) === + JSON.stringify(catalogKeys.itemDetail("movie-1", undefined)) + ? { content_id: "movie-1" } + : undefined, + ); + const view = renderLayout(); + setRoute("/item/movie-1", "item"); + + act(() => + view.rerender( + + + + + , + ), + ); + + expect( + screen.getByRole("status", { name: "details-ready" }), + ).toHaveTextContent("true"); + expect(vi.getTimerCount()).toBe(0); + }); + it("reveals as soon as the surface settles", () => { const view = renderLayout(); setRoute("/item/movie-1", "item"); diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 12db38539..d1e3a5f41 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useState } from "react"; import { Link, useLocation, useNavigate } from "react-router"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; import { Menu, Search } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { @@ -64,6 +64,7 @@ export default function Layout({ children }: LayoutProps) { const isHomePath = location.pathname === "/"; const isLibraryRoute = location.pathname.startsWith("/library/"); const isItemRoute = location.pathname.startsWith("/item/"); + const itemRouteLocation = `${location.pathname}${location.search}`; // Breakpoint changes naturally cause other layout renders; navigation only // needs the viewport value at the moment it is attempted. const hasDesktopSidebar = window.matchMedia("(min-width: 64rem)").matches; @@ -136,6 +137,15 @@ export default function Layout({ children }: LayoutProps) { // can never leave the item route on its lightweight shell indefinitely. useEffect(() => { if (!isItemRoute || !pendingLocationKey) return; + + // The gate stages the collapse so the detail skeleton is not what animates + // into view. A prefetched detail has no skeleton to hide, so waiting for + // the sidebar would only delay a page that is already ready to paint. + if (hasCachedItemDetail(queryClient, itemRouteLocation)) { + revealItemDetails(pendingLocationKey); + return; + } + const startedAt = Date.now(); let timer: number; let cancelled = false; @@ -168,7 +178,13 @@ export default function Layout({ children }: LayoutProps) { cancelled = true; window.clearTimeout(timer); }; - }, [isItemRoute, pendingLocationKey, revealItemDetails]); + }, [ + isItemRoute, + itemRouteLocation, + pendingLocationKey, + queryClient, + revealItemDetails, + ]); const handleSidebarTransitionEnd = useCallback( (event: ReactTransitionEvent) => { @@ -365,3 +381,24 @@ export default function Layout({ children }: LayoutProps) { ); } + +/** + * Reports whether the detail for the item route currently being entered is + * already in the query cache — either prefetched by `beginItemNavigation` or + * left behind by an earlier visit. + */ +function hasCachedItemDetail( + queryClient: QueryClient, + itemRouteLocation: string, +): boolean { + const target = parseItemNavigationHref( + itemRouteLocation, + window.location.origin, + ); + if (!target) return false; + return ( + queryClient.getQueryData( + catalogKeys.itemDetail(target.contentId, target.libraryId), + ) !== undefined + ); +} diff --git a/web/src/components/MediaCarousel.tsx b/web/src/components/MediaCarousel.tsx index ffe7df2e9..f0755862a 100644 --- a/web/src/components/MediaCarousel.tsx +++ b/web/src/components/MediaCarousel.tsx @@ -1,9 +1,11 @@ import { Children } from "react"; -import type { ReactNode } from "react"; +import type { CSSProperties, ReactNode } from "react"; import { Link } from "react-router"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; import { useCarouselEmbla } from "@/hooks/useCarouselEmbla"; +import { useUICustomization } from "@/hooks/useUICustomization"; +import { carouselIntrinsicHeight } from "@/lib/uiCustomization"; interface MediaCarouselProps { title: string; @@ -38,6 +40,7 @@ export default function MediaCarousel({ }: MediaCarouselProps) { const { emblaRef, canScrollPrev, canScrollNext, scrollPrev, scrollNext } = useCarouselEmbla(); + const { cardPresentation } = useUICustomization(); // Page-edge padding is opt-out so the carousel can also be embedded in an // already-padded container without double-padding the header and cards. const headerPadX = edgePadding ? " px-4 sm:px-6 lg:px-10 xl:px-12" : ""; @@ -54,7 +57,16 @@ export default function MediaCarousel({ : Children.toArray(children); return ( -
+
{titleHref ? ( diff --git a/web/src/components/MediaItemMenu.test.tsx b/web/src/components/MediaItemMenu.test.tsx index 13c53192c..475617c0e 100644 --- a/web/src/components/MediaItemMenu.test.tsx +++ b/web/src/components/MediaItemMenu.test.tsx @@ -430,6 +430,7 @@ describe("MediaItemMenu trigger visibility", () => { expect(className).not.toContain("opacity-"); expect(className).not.toContain("group-hover"); expect(className).not.toContain("group-focus-within"); + expect(className).not.toContain("backdrop-blur"); expect(className).toContain("focus-visible:ring-2"); expect(className).toContain("size-6"); expect(className).toContain("sm:size-8"); @@ -443,6 +444,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="movie" userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" + quickActionMode="both" /> , ); @@ -467,6 +469,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="movie" userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" + quickActionMode="both" /> , ); @@ -491,6 +494,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="movie" userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" + quickActionMode="both" /> , ); @@ -516,6 +520,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="movie" userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" + quickActionMode="both" /> , ); @@ -536,6 +541,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="movie" userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" + quickActionMode="both" /> , ); @@ -568,6 +574,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="movie" userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" + quickActionMode="both" /> , ); @@ -611,6 +618,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="movie" userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" + quickActionMode="both" /> , ); @@ -632,6 +640,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="movie" userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" + quickActionMode="both" /> , ); @@ -680,6 +689,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="movie" userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="wide" + quickActionMode="both" /> , ); @@ -694,6 +704,7 @@ describe("MediaItemMenu trigger visibility", () => { userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="wide" showWatchedShortcut + quickActionMode="both" /> , ); @@ -709,6 +720,7 @@ describe("MediaItemMenu trigger visibility", () => { userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" narrowPosterActions + quickActionMode="both" /> , ); @@ -733,6 +745,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="movie" userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" + quickActionMode="both" /> , ); @@ -749,6 +762,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="episode" userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" + quickActionMode="both" /> , ); @@ -763,6 +777,7 @@ describe("MediaItemMenu trigger visibility", () => { userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" showWatchedShortcut + quickActionMode="both" /> , ); @@ -770,6 +785,44 @@ describe("MediaItemMenu trigger visibility", () => { expect(screen.getByRole("button", { name: "Mark Watched" })).toBeTruthy(); }); + it("shows only the quick actions selected by the resolved profile mode", () => { + const userState = { + played: false, + is_favorite: false, + in_watchlist: false, + }; + const renderMenu = (quickActionMode: "favorites" | "watched" | "none") => ( + + + + ); + const { rerender } = render(renderMenu("favorites")); + + expect( + screen.getByRole("button", { name: "Add to favorites" }), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Mark Watched" })).toBeNull(); + + rerender(renderMenu("watched")); + expect( + screen.queryByRole("button", { name: "Add to favorites" }), + ).toBeNull(); + expect(screen.getByRole("button", { name: "Mark Watched" })).toBeTruthy(); + + rerender(renderMenu("none")); + expect( + screen.queryByRole("button", { name: "Add to favorites" }), + ).toBeNull(); + expect(screen.queryByRole("button", { name: "Mark Watched" })).toBeNull(); + expect(screen.getByRole("button", { name: "More actions" })).toBeTruthy(); + }); + it("uses matching action icons and sizes the menu to its longest entry", async () => { mocks.authState = { user: { role: "admin" } }; render( @@ -780,6 +833,7 @@ describe("MediaItemMenu trigger visibility", () => { userState={{ played: true, is_favorite: true, in_watchlist: false }} variant="poster" dismissAction={{ itemId: "movie-1", surface: "continue_watching" }} + quickActionMode="both" /> , ); @@ -827,6 +881,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="movie" userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" + quickActionMode="both" /> , ); @@ -861,6 +916,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="movie" userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" + quickActionMode="both" /> , ); @@ -896,6 +952,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="movie" userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" + quickActionMode="both" /> , ); @@ -935,6 +992,7 @@ describe("MediaItemMenu trigger visibility", () => { variant="wide" showCollectionActions={false} showWatchedShortcut + quickActionMode="both" /> ); @@ -958,6 +1016,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="movie" userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" + quickActionMode="both" /> , ); @@ -999,6 +1058,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="movie" userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" + quickActionMode="both" /> , ); @@ -1032,6 +1092,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="movie" userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" + quickActionMode="both" /> , ); @@ -1057,6 +1118,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="movie" userState={{ played: false, is_favorite: true, in_watchlist: false }} variant="poster" + quickActionMode="both" /> , ); @@ -1085,6 +1147,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="movie" userState={{ played: false, is_favorite: true, in_watchlist: false }} variant="poster" + quickActionMode="both" /> , ); @@ -1113,6 +1176,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="movie" userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" + quickActionMode="both" /> , ); @@ -1134,6 +1198,7 @@ describe("MediaItemMenu trigger visibility", () => { mediaType="movie" userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="wide" + quickActionMode="both" /> , ); @@ -1150,6 +1215,7 @@ describe("MediaItemMenu trigger visibility", () => { userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" showCollectionActions={false} + quickActionMode="both" /> , ); @@ -1168,6 +1234,7 @@ describe("MediaItemMenu trigger visibility", () => { userState={{ played: false, is_favorite: false, in_watchlist: false }} variant="poster" showFavoriteShortcut={false} + quickActionMode="both" /> , ); @@ -1205,6 +1272,7 @@ describe("MediaItemMenu long-press action sheet", () => { variant="poster" longPressRef={cardRef} itemTitle="Apex" + quickActionMode="both" />
@@ -1332,4 +1400,32 @@ describe("MediaItemMenu long-press action sheet", () => { expect(mocks.toggleWatched).toHaveBeenCalledWith(true); expect(screen.queryByRole("dialog")).toBeNull(); }); + + it("keeps long press while omitting desktop-only shortcut trees on coarse-pointer devices", () => { + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + + render(); + + expect(screen.queryByRole("button", { name: "Mark Watched" })).toBeNull(); + expect( + screen.queryByRole("button", { name: "Add to favorites" }), + ).toBeNull(); + expect(screen.getByRole("button", { name: "More actions" })).toBeTruthy(); + + pressCard(); + holdPastLongPress(); + + expect( + within(screen.getByRole("dialog")).getByRole("button", { + name: "Mark Watched", + }), + ).toBeTruthy(); + }); }); diff --git a/web/src/components/MediaItemMenu.tsx b/web/src/components/MediaItemMenu.tsx index d600d6766..26a351cbf 100644 --- a/web/src/components/MediaItemMenu.tsx +++ b/web/src/components/MediaItemMenu.tsx @@ -64,6 +64,7 @@ import { SheetTitle, } from "@/components/ui/sheet"; import { useLongPress } from "@/hooks/useLongPress"; +import { useMediaQuery } from "@/hooks/useMediaQuery"; import { cn } from "@/lib/utils"; import { useWatchPlaybackController } from "@/playback/watchPlaybackContext"; import { buildMediaPlayHref } from "@/lib/mediaNavigation"; @@ -75,9 +76,22 @@ import { } from "@/components/mediaItemMenuTrigger"; import { useUICustomization } from "@/hooks/useUICustomization"; import { MediaActionIcon } from "@/components/mediaActionIcons"; +import { + showsFavoriteQuickAction, + showsWatchedQuickAction, + type CardQuickActionMode, +} from "@/lib/cardQuickActions"; type MediaItemType = ItemDetail["type"]; +const FINE_POINTER_QUERY = "(any-hover: hover) and (any-pointer: fine)"; + +function useHasFinePointer() { + // Preserve the established quick actions in SSR, tests, and older browsers + // without matchMedia. Touch-capable modern browsers report this accurately. + return useMediaQuery(FINE_POINTER_QUERY, true); +} + type MediaItemMenuEntry = | { kind: "action"; @@ -127,6 +141,8 @@ interface MediaItemMenuProps { showWatchedShortcut?: boolean; /** Uses smaller poster controls on narrow catalog cards. */ narrowPosterActions?: boolean; + /** Which watched/favorite shortcuts appear outside the overflow menu. */ + quickActionMode?: CardQuickActionMode; /** Card root whose long press opens the touch action sheet. */ longPressRef?: RefObject; /** Heading for the touch action sheet. */ @@ -713,6 +729,7 @@ export default function MediaItemMenu({ hasPartialProgress = false, showWatchedShortcut = false, narrowPosterActions = false, + quickActionMode = "none", longPressRef, itemTitle, }: MediaItemMenuProps) { @@ -726,6 +743,7 @@ export default function MediaItemMenu({ const canCurateMetadata = profileIsResolved && canCurateMetadataForUser(user, currentProfile); const { cardPresentation } = useUICustomization(); + const hasFinePointer = useHasFinePointer(); const [currentUserState, setCurrentUserState] = useState(userState); const lastSyncedUserStateRef = useRef(userState); const [refreshDialogOpen, setRefreshDialogOpen] = useState(false); @@ -734,6 +752,12 @@ export default function MediaItemMenu({ null, ); const [actionSheetOpen, setActionSheetOpen] = useState(false); + // A card that has never opened one of these surfaces mounts nothing — a home + // page holds hundreds of cards. Once opened, the overlay stays mounted so + // closing runs its exit animation and restores focus instead of vanishing. + const [actionSheetMounted, setActionSheetMounted] = useState(false); + const [refreshDialogMounted, setRefreshDialogMounted] = useState(false); + const [filesDialogMounted, setFilesDialogMounted] = useState(false); const menuTriggerRef = useRef(null); const lastMenuInteractionRef = useRef<"keyboard" | "pointer" | null>(null); const pointerClosedMenuRef = useRef(false); @@ -787,12 +811,17 @@ export default function MediaItemMenu({ dismissLabel, }); useLongPress(longPressRef, { - onLongPress: () => setActionSheetOpen(true), + onLongPress: () => { + setActionSheetMounted(true); + setActionSheetOpen(true); + }, enabled: model.length > 0, }); const showPosterFavorite = + hasFinePointer && variant === "poster" && showFavoriteShortcut && + showsFavoriteQuickAction(quickActionMode) && model.some( (entry) => entry.kind === "action" && entry.key === "toggleFavorite", ); @@ -802,6 +831,8 @@ export default function MediaItemMenu({ const rootPosterSupportsWatchedShortcut = variant === "poster" && (mediaType === "movie" || mediaType === "series"); const showWatchedQuickAction = + hasFinePointer && + showsWatchedQuickAction(quickActionMode) && hasWatchedAction && (rootPosterSupportsWatchedShortcut || showWatchedShortcut); const posterActionDensity: PosterActionDensity = @@ -897,6 +928,7 @@ export default function MediaItemMenu({ return; } case "viewDetails": { + setFilesDialogMounted(true); setFilesDialogOpen(true); return; } @@ -912,6 +944,7 @@ export default function MediaItemMenu({ return; } case "refreshMetadata": { + setRefreshDialogMounted(true); setRefreshDialogOpen(true); return; } @@ -1085,25 +1118,29 @@ export default function MediaItemMenu({ )}
- { - setActionSheetOpen(false); - void handleAction(actionKey); - }} - /> - + {actionSheetMounted && ( + { + setActionSheetOpen(false); + void handleAction(actionKey); + }} + /> + )} + {refreshDialogMounted && ( + + )} {metadataAction && ( setMetadataAction(null)} /> )} - {mediaType === "manga" && ( + {mediaType === "manga" && filesDialogMounted && ( ))} diff --git a/web/src/components/RealtimeEventsProvider.tsx b/web/src/components/RealtimeEventsProvider.tsx index dd3953bf4..abae2ff79 100644 --- a/web/src/components/RealtimeEventsProvider.tsx +++ b/web/src/components/RealtimeEventsProvider.tsx @@ -29,7 +29,13 @@ import { type RealtimeConnectionState, type RealtimeEventsContextValue, } from "@/components/realtimeEventsContext"; -import { invalidateCatalogState } from "@/components/realtimeCatalogInvalidation"; +import { + createCatalogInvalidationScheduler, + invalidateCatalogState, + scheduleProgressHomeRefresh, + userStateChangeAffectsSectionMembership, +} from "@/components/realtimeCatalogInvalidation"; +import { bumpHomeRefreshSignal } from "@/pages/homeSurfaceRefresh"; import { useAuth } from "@/hooks/useAuth"; import { useIsActingAdmin } from "@/hooks/useIsActingAdmin"; import { usePageActivity } from "@/hooks/usePageActivity"; @@ -37,6 +43,7 @@ import { adminKeys, historyImportKeys, libraryKeys, + sectionKeys, } from "@/hooks/queries/keys"; import { scheduleMediaSurfaceInvalidation, @@ -73,10 +80,6 @@ const CATALOG_ITEM_CHANGED_EVENTS = new Set([ "library.item_added", "metadata.updated", ]); -const SCOPED_CATALOG_LIBRARY_EVENTS = new Set([ - "catalog.library.changed", - "library.changed", -]); const DASHBOARD_QUERY_KEYS = [ adminKeys.stats(), adminKeys.sessions(), @@ -309,6 +312,14 @@ function catalogEventLibraryID(data: unknown) { : undefined; } +function catalogEventContentID(data: unknown) { + if (!data || typeof data !== "object" || !("content_id" in data)) { + return undefined; + } + const value = (data as { content_id?: unknown }).content_id; + return typeof value === "string" && value ? value : undefined; +} + function handleJobSideEffects( queryClient: QueryClient, job: AdminJob, @@ -512,6 +523,18 @@ function handleUserStateEvent( } : { skipSimilarItems: true }, ); + // Resetting home's load queue re-runs every section fetch. Membership + // changes do it immediately; progress ticks coalesce into one trailing + // refresh per window so an open home still catches another client's + // playback without a per-tick storm. Mark the home sections stale before + // an immediate reset because the surface invalidation above is debounced. + if (userStateChangeAffectsSectionMembership(payload.change)) { + void queryClient + .invalidateQueries({ queryKey: sectionKeys.home(), refetchType: "none" }) + .then(() => bumpHomeRefreshSignal(queryClient)); + } else { + scheduleProgressHomeRefresh(queryClient); + } void queryClient.invalidateQueries({ queryKey: adminKeys.stats(), refetchType: allowDashboardRefetch ? "active" : "none", @@ -531,6 +554,10 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { !isDashboardRoute || pageActivity.canPollDashboard; const [connectionState, setConnectionState] = useState("connecting"); + const catalogInvalidation = useMemo( + () => createCatalogInvalidationScheduler(queryClient), + [queryClient], + ); const reconnectTimerRef = useRef(undefined); const profileRebindAttemptsRef = useRef(0); const nextReconnectDelayRef = useRef(null); @@ -556,6 +583,8 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { canApplyRealtimeUpdatesRef.current = pageActivity.canApplyRealtimeUpdates; allowDashboardRealtimeUpdatesRef.current = allowDashboardRealtimeUpdates; + useEffect(() => () => catalogInvalidation.cancel(), [catalogInvalidation]); + const settleWaiterRef = useRef<(job: AdminJob) => void>(() => {}); settleWaiterRef.current = (job: AdminJob) => { const waiter = waitersRef.current.get(job.id); @@ -758,33 +787,17 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { switch (message.channel) { case "catalog": { - const eventLibraryID = catalogEventLibraryID(message.data); - if (CATALOG_ITEM_CHANGED_EVENTS.has(message.event)) { - invalidateCatalogState(queryClient, { - itemId: - typeof message.data === "object" && - message.data && - "content_id" in message.data - ? (message.data as { content_id?: string }).content_id - : undefined, - libraryId: eventLibraryID, - allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current, - includeLibraryLists: false, - }); - } else if ( - SCOPED_CATALOG_LIBRARY_EVENTS.has(message.event) && - eventLibraryID - ) { - invalidateCatalogState(queryClient, { - libraryId: eventLibraryID, - allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current, - }); - } else { - invalidateCatalogState(queryClient, { - libraryId: eventLibraryID, - allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current, - }); - } + const isItemChange = CATALOG_ITEM_CHANGED_EVENTS.has(message.event); + catalogInvalidation.schedule({ + itemId: isItemChange + ? catalogEventContentID(message.data) + : undefined, + libraryId: catalogEventLibraryID(message.data), + allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current, + // An item changing inside a library does not change the set of + // libraries, so the admin library lists stay untouched. + includeLibraryLists: !isItemChange, + }); } break; case "jobs": @@ -864,11 +877,6 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { }); }, [authenticatedUserID, pageActivity.canApplyRealtimeUpdates, queryClient]); - const handleSnapshotRef = useRef(handleSnapshot); - handleSnapshotRef.current = handleSnapshot; - const handleEventRef = useRef(handleEvent); - handleEventRef.current = handleEvent; - useEffect(() => { if (!authenticatedUserID || !pageActivity.canApplyRealtimeUpdates) { setConnectionState("disconnected"); @@ -877,8 +885,6 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { let closedByEffect = false; let activeSocket: WebSocket | null = null; - // Capture once; waiters are mutated in place for the lifetime of this provider. - const waiters = waitersRef.current; const clearReconnect = () => { if (reconnectTimerRef.current !== undefined) { @@ -993,10 +999,10 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { return; } case "snapshot": - handleSnapshotRef.current(message); + handleSnapshot(message); return; case "event": - handleEventRef.current(message); + handleEvent(message); return; case "error": return; @@ -1029,7 +1035,7 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { return () => { closedByEffect = true; clearReconnect(); - for (const [jobId, waiter] of waiters) { + for (const [jobId, waiter] of waitersRef.current) { window.clearTimeout(waiter.timeoutId); waiter.reject( new Error( @@ -1037,7 +1043,7 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { ), ); } - waiters.clear(); + waitersRef.current.clear(); const socket = activeSocket; if (socket && socketRef.current === socket) { socketRef.current = null; diff --git a/web/src/components/SectionItemCard.test.tsx b/web/src/components/SectionItemCard.test.tsx index fd2745abf..28c25d4e4 100644 --- a/web/src/components/SectionItemCard.test.tsx +++ b/web/src/components/SectionItemCard.test.tsx @@ -24,10 +24,6 @@ vi.mock("@/components/overlays/CardOverlays", () => ({ default: () => null, })); -vi.mock("@/hooks/useOverlayPrefs", () => ({ - useOverlayPrefs: () => ({ prefs: null }), -})); - describe("SectionItemCard", () => { it("encodes item links while preserving library context", () => { const markup = renderToStaticMarkup( diff --git a/web/src/components/SectionItemCard.tsx b/web/src/components/SectionItemCard.tsx index cdc5edeee..170fcf6fb 100644 --- a/web/src/components/SectionItemCard.tsx +++ b/web/src/components/SectionItemCard.tsx @@ -1,13 +1,14 @@ import { useRef } from "react"; import { useImageLoaded } from "@/hooks/useImageLoaded"; -import { ArtworkImage } from "@/components/ArtworkImage"; -import { POSTER_WIDTHS } from "@/lib/artworkUrl"; import ViewTransitionLink from "@/components/ViewTransitionLink"; import MediaItemMenu from "@/components/MediaItemMenu"; import CardOverlays from "@/components/overlays/CardOverlays"; import { decodeThumbhash } from "@/lib/thumbhash"; -import { useOverlayPrefs } from "@/hooks/useOverlayPrefs"; -import { overlayDataFromSectionItem } from "@/lib/overlays"; +import { + overlayDataFromSectionItem, + type CardOverlayPrefs, +} from "@/lib/overlays"; +import type { CardQuickActionMode } from "@/lib/cardQuickActions"; import { buildEpisodeCardLabels } from "@/lib/episodeCardLabels"; import { formatUpcomingDate, @@ -24,18 +25,21 @@ import CardPlayOverlay from "@/components/CardPlayOverlay"; interface SectionItemCardProps { item: SectionItem; libraryId?: number; + overlayPrefs?: CardOverlayPrefs | null; + quickActionMode?: CardQuickActionMode; } export default function SectionItemCard({ item, libraryId, + overlayPrefs = null, + quickActionMode = "none", }: SectionItemCardProps) { const { loaded, onLoad } = useImageLoaded(item.poster_url); const thumbhashUrl = item.poster_thumbhash ? decodeThumbhash(item.poster_thumbhash) : ""; const itemHref = buildItemHref({ contentId: item.content_id, libraryId }); - const { prefs: overlayPrefs } = useOverlayPrefs(); const upcomingEvent = item.upcoming_event; const subtitle = upcomingEvent ? formatUpcomingSubtitle(upcomingEvent) : ""; const airDateLabel = upcomingEvent @@ -77,13 +81,9 @@ export default function SectionItemCard({ } > {item.poster_url ? ( - diff --git a/web/src/components/SectionRow.tsx b/web/src/components/SectionRow.tsx index f8f45cf5f..4fb0a9a4e 100644 --- a/web/src/components/SectionRow.tsx +++ b/web/src/components/SectionRow.tsx @@ -57,7 +57,7 @@ function SectionPinButton({ export default function SectionRow({ section, libraryId }: SectionRowProps) { const navigate = useViewTransitionNavigate(); const browseSupported = isSectionBrowseSupported(section.section_type); - const { prefs: overlayPrefs } = useOverlayPrefs(); + const { prefs: overlayPrefs, quickActionMode } = useOverlayPrefs(); const { cardPresentation } = useUICustomization(); const posterWidthClasses = carouselCardWidthClasses( cardPresentation.poster_size, @@ -130,6 +130,7 @@ export default function SectionRow({ section, libraryId }: SectionRowProps) { sectionItem={item} libraryId={libraryId} overlayPrefs={overlayPrefs} + quickActionMode={quickActionMode} variant={cardVariant} /> )); @@ -140,7 +141,12 @@ export default function SectionRow({ section, libraryId }: SectionRowProps) { className={posterWidthClasses} role="listitem" > - +
))} diff --git a/web/src/components/ThemeSwitcher.tsx b/web/src/components/ThemeSwitcher.tsx deleted file mode 100644 index dd7a05724..000000000 --- a/web/src/components/ThemeSwitcher.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useTheme } from "@/hooks/useTheme"; -import { CURATED_THEME_IDS, THEMES } from "@/lib/themes"; -import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; -import { Check } from "lucide-react"; - -export default function ThemeSwitcher() { - const { theme, setTheme, previewTheme, resetPreviewTheme } = useTheme(); - - return ( -
- {CURATED_THEME_IDS.map((id) => { - const def = THEMES[id]; - const isActive = theme === id; - return ( - setTheme(id)} - onFocus={() => previewTheme(id)} - onBlur={resetPreviewTheme} - onMouseEnter={() => previewTheme(id)} - onMouseLeave={resetPreviewTheme} - className={`rounded-xl border px-2.5 py-2.5 ${isActive ? "border-primary/35 bg-accent/70" : "border-transparent bg-transparent"} flex items-start gap-3`} - > -
-
-
-
-
-
-
-
- - {def.label} - - {isActive && ( - - )} -
- {def.description ? ( -

- {def.description} -

- ) : null} -
- - ); - })} -
- ); -} diff --git a/web/src/components/UnsavedChangesGuard.test.tsx b/web/src/components/UnsavedChangesGuard.test.tsx new file mode 100644 index 000000000..2e2e7c215 --- /dev/null +++ b/web/src/components/UnsavedChangesGuard.test.tsx @@ -0,0 +1,152 @@ +// @vitest-environment jsdom + +import { useState } from "react"; +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createMemoryRouter, Link, RouterProvider } from "react-router"; +import { describe, expect, it } from "vitest"; + +import { useReportUnsavedChanges } from "@/hooks/useUnsavedChanges"; + +import { UnsavedChangesGuard } from "./UnsavedChangesGuard"; + +// Radix marks the rest of the page inert while a modal dialog is open, which +// jsdom reports as pointer-events: none on everything below . +const user = userEvent.setup({ pointerEventsCheck: 0 }); + +function DraftPage() { + const [draft, setDraft] = useState(""); + useReportUnsavedChanges(draft !== ""); + + return ( +
+

Draft page

+ + Other page + Same page, other tab +
+ ); +} + +function renderGuard(initialEntries: string[] = ["/draft"]) { + const router = createMemoryRouter( + [ + { + path: "/draft", + element: ( + <> + + + + ), + }, + { path: "/other", element:

Other page

}, + ], + { initialEntries }, + ); + + return { router, ...render() }; +} + +describe("UnsavedChangesGuard", () => { + it("lets a clean page navigate away without asking", async () => { + renderGuard(); + + await user.click(screen.getByRole("link", { name: "Other page" })); + + expect( + screen.getByRole("heading", { name: "Other page" }), + ).toBeInTheDocument(); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + }); + + it("keeps the page and its edits when the prompt is cancelled", async () => { + renderGuard(); + + const field = screen.getByLabelText("Server name"); + await user.type(field, "Casa"); + await user.click(screen.getByRole("link", { name: "Other page" })); + + expect( + await screen.findByRole("alertdialog", { + name: "Discard unsaved changes?", + }), + ).toBeInTheDocument(); + // The page behind an open modal is aria-hidden, so it is only reachable by + // text while the prompt is up. + expect(screen.getByText("Draft page")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Draft page" }), + ).toBeInTheDocument(); + expect(screen.getByLabelText("Server name")).toHaveValue("Casa"); + }); + + it("navigates once the edits are discarded", async () => { + renderGuard(); + + await user.type(screen.getByLabelText("Server name"), "Casa"); + await user.click(screen.getByRole("link", { name: "Other page" })); + await user.click(await screen.findByRole("button", { name: "Discard" })); + + expect( + await screen.findByRole("heading", { name: "Other page" }), + ).toBeInTheDocument(); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + }); + + it("prompts on browser back as well as on in-app links", async () => { + const { router } = renderGuard(["/other", "/draft"]); + + await user.type(screen.getByLabelText("Server name"), "Casa"); + await act(async () => { + await router.navigate(-1); + }); + + expect(await screen.findByRole("alertdialog")).toBeInTheDocument(); + expect(screen.getByText("Draft page")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Discard" })); + + expect( + await screen.findByRole("heading", { name: "Other page" }), + ).toBeInTheDocument(); + }); + + it("does not prompt when only the search string changes", async () => { + renderGuard(); + + await user.type(screen.getByLabelText("Server name"), "Casa"); + await user.click( + screen.getByRole("link", { name: "Same page, other tab" }), + ); + + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Draft page" }), + ).toBeInTheDocument(); + }); + + it("stops guarding once the form goes clean", async () => { + renderGuard(); + + const field = screen.getByLabelText("Server name"); + await user.type(field, "Casa"); + await user.clear(field); + await user.click(screen.getByRole("link", { name: "Other page" })); + + expect( + screen.getByRole("heading", { name: "Other page" }), + ).toBeInTheDocument(); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/UnsavedChangesGuard.tsx b/web/src/components/UnsavedChangesGuard.tsx new file mode 100644 index 000000000..9421bf332 --- /dev/null +++ b/web/src/components/UnsavedChangesGuard.tsx @@ -0,0 +1,80 @@ +import { useEffect, useRef } from "react"; +import { useBlocker } from "react-router"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useHasUnsavedChanges } from "@/hooks/useUnsavedChanges"; + +/** + * Confirms before an in-app navigation throws away staged edits. + * + * Mount it once inside the shell that owns the forms (it covers everything they + * can navigate to: nav rails, back links, the surrounding sidebar, and browser + * back/forward, since every one of those goes through the router). Forms + * announce themselves through `useReportUnsavedChanges`; tab close and reload + * stay with the `beforeunload` guard in `useSettingsForm`, which is the only + * thing the browser lets us intercept there. + * + * `useBlocker` needs a data router — the app mounts one in `App.tsx`, and tests + * rendering this component need `createMemoryRouter` rather than + * ``. + */ +export function UnsavedChangesGuard() { + const hasUnsavedChanges = useHasUnsavedChanges(); + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => + // Search-only updates (a tab id, a filter) keep the form mounted, so they + // lose nothing and must not prompt. + hasUnsavedChanges && currentLocation.pathname !== nextLocation.pathname, + ); + const proceeding = useRef(false); + + useEffect(() => { + if (blocker.state !== "blocked") { + proceeding.current = false; + } + }, [blocker.state]); + + return ( + { + // Cancel, Escape and the overlay all close the dialog and mean "stay + // here". Discard closes it too, but only after the navigation is + // already on its way — resetting then would resurrect the blocker. + if (open || proceeding.current) return; + blocker.reset?.(); + }} + > + + + Discard unsaved changes? + + This page has edits that were never saved. Leaving now throws them + away. + + + + Cancel + { + proceeding.current = true; + blocker.proceed?.(); + }} + > + Discard + + + + + ); +} diff --git a/web/src/components/admin/BrandingAssetField.test.tsx b/web/src/components/admin/BrandingAssetField.test.tsx new file mode 100644 index 000000000..95102f175 --- /dev/null +++ b/web/src/components/admin/BrandingAssetField.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment jsdom + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/hooks/queries/admin/branding", () => ({ + useUploadBrandingAsset: () => ({ mutate: vi.fn(), isPending: false }), + useDeleteBrandingAsset: () => ({ mutate: vi.fn(), isPending: false }), +})); + +import { BrandingAssetField } from "./BrandingAssetField"; +import { BRANDING_ASSET_SPECS } from "./brandingAssetSpecs"; + +describe("BrandingAssetField", () => { + it("previews the bundled default, dimmed and captioned, when nothing is uploaded", () => { + render( + , + ); + + const preview = screen.getByAltText("Logo (wordmark) preview"); + expect(preview).toHaveAttribute("src", "/prairie-wordmark-sidebar.png"); + expect(preview.className).toContain("opacity-40"); + expect(screen.getByText("Default")).toBeInTheDocument(); + // Nothing to remove while the default is what is being served. + expect( + screen.queryByRole("button", { name: "Remove Logo (wordmark)" }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Upload/ })).toBeInTheDocument(); + }); + + it("previews the main asset an empty light slot actually falls back to", () => { + render( + , + ); + + // The spec has no bundled light asset; what visitors see is the main + // logo, so that is what the empty slot must preview. + const preview = screen.getByAltText( + "Logo (wordmark, light themes) preview", + ); + expect(preview).toHaveAttribute("src", "/custom/wordmark.webp"); + expect(preview.className).toContain("opacity-40"); + expect(screen.getByText("Falls back to the main logo")).toBeInTheDocument(); + }); + + it("labels the empty login background as the theme gradient instead of an image", () => { + render( + , + ); + + expect(screen.getByText("Theme gradient")).toBeInTheDocument(); + expect( + screen.queryByAltText("Login background preview"), + ).not.toBeInTheDocument(); + }); + + it("shows an uploaded asset undimmed, with a remove action", () => { + render( + , + ); + + const preview = screen.getByAltText("Favicon preview"); + expect(preview).toHaveAttribute( + "src", + "/api/v1/branding/assets/favicon?v=abc.png", + ); + expect(preview.className).not.toContain("opacity-40"); + expect(screen.queryByText("Default")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Remove Favicon" }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Replace/ })).toBeInTheDocument(); + }); + + it("states the pipeline's own dimensions and caps per slot", () => { + render( + , + ); + + expect( + screen.getByText(BRANDING_ASSET_SPECS.mark.guidance), + ).toBeInTheDocument(); + expect( + screen.getByText("Shown in the collapsed sidebar."), + ).toBeInTheDocument(); + }); + + // The guidance is only useful if it keeps matching internal/branding/assets.go + // and internal/imageutil/imageutil.go; these are the numbers to change on both + // sides together. + it("keeps the stored sizes and upload caps in step with the server pipeline", () => { + expect(BRANDING_ASSET_SPECS.wordmark.storedPx).toBe(640); + expect(BRANDING_ASSET_SPECS.mark.storedPx).toBe(512); + expect(BRANDING_ASSET_SPECS.login_bg.storedPx).toBe(2560); + // The favicon is stored byte-for-byte so .ico and .svg keep working. + expect(BRANDING_ASSET_SPECS.favicon.storedPx).toBeNull(); + + expect(BRANDING_ASSET_SPECS.wordmark.maxUploadBytes).toBe(8 << 20); + expect(BRANDING_ASSET_SPECS.mark.maxUploadBytes).toBe(8 << 20); + expect(BRANDING_ASSET_SPECS.favicon.maxUploadBytes).toBe(1 << 20); + expect(BRANDING_ASSET_SPECS.login_bg.maxUploadBytes).toBe(12 << 20); + + for (const spec of Object.values(BRANDING_ASSET_SPECS)) { + if (spec.storedPx !== null) { + expect(spec.guidance).toContain(String(spec.storedPx)); + } + expect(spec.guidance).toContain( + `${spec.maxUploadBytes / (1024 * 1024)} MB`, + ); + } + }); +}); diff --git a/web/src/components/admin/BrandingAssetField.tsx b/web/src/components/admin/BrandingAssetField.tsx index 082e22fb3..1912c0277 100644 --- a/web/src/components/admin/BrandingAssetField.tsx +++ b/web/src/components/admin/BrandingAssetField.tsx @@ -1,5 +1,5 @@ import { useRef } from "react"; -import { ImageOff, Loader2, Trash2, Upload } from "lucide-react"; +import { Loader2, Trash2, Upload } from "lucide-react"; import { cn } from "@/lib/utils"; import { @@ -7,6 +7,7 @@ import { useUploadBrandingAsset, type BrandingAssetKind, } from "@/hooks/queries/admin/branding"; +import { BRANDING_ASSET_SPECS } from "./brandingAssetSpecs"; interface BrandingAssetFieldProps { label: string; @@ -25,6 +26,12 @@ interface BrandingAssetFieldProps { * so they would be invisible on the default muted tile in a dark admin theme. */ previewBg?: "light"; + /** + * What an empty slot actually serves when the kind has no bundled default of + * its own — the light variants fall back to the main logo/icon, so the + * preview must show that image, not a placeholder. + */ + fallbackUrl?: string | null; } export function BrandingAssetField({ @@ -36,11 +43,19 @@ export function BrandingAssetField({ enabled, preview = "wide", previewBg, + fallbackUrl, }: BrandingAssetFieldProps) { const inputRef = useRef(null); const upload = useUploadBrandingAsset(); const remove = useDeleteBrandingAsset(); const busy = upload.isPending || remove.isPending; + const spec = BRANDING_ASSET_SPECS[kind]; + // An empty slot is not "no image" — the bundled default (or, for the light + // variants, the main asset they fall back to) is what visitors see. Show + // that instead of a placeholder glyph, dimmed and captioned so it never + // reads as the admin's own upload. + const shownUrl = currentUrl ?? spec.defaultUrl ?? fallbackUrl ?? null; + const showingDefault = currentUrl === null; const handleFile = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -52,21 +67,34 @@ export function BrandingAssetField({ return (
-
- {currentUrl ? ( - {`${label} - ) : ( - +
+
+ {shownUrl ? ( + {`${label} + ) : ( + + )} +
+ {showingDefault && ( + + {spec.emptyCaption} + )}
@@ -75,6 +103,9 @@ export function BrandingAssetField({ {description && (

{description}

)} +

+ {spec.guidance} +

+ // py matches SettingFieldRow so the action reads as one more row in the + // group instead of hugging the hairline under the last field. +
+ +
+
+ ); +} diff --git a/web/src/pages/admin-settings/RestartServerButton.tsx b/web/src/components/admin/RestartServerButton.tsx similarity index 64% rename from web/src/pages/admin-settings/RestartServerButton.tsx rename to web/src/components/admin/RestartServerButton.tsx index 109dbba6c..26d20b65b 100644 --- a/web/src/pages/admin-settings/RestartServerButton.tsx +++ b/web/src/components/admin/RestartServerButton.tsx @@ -1,11 +1,23 @@ -import { useState } from "react"; +import { useState, type ComponentProps } from "react"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { api } from "@/api/client"; import { RotateCcw } from "lucide-react"; import { toast } from "sonner"; -export function RestartServerButton() { +export interface RestartServerButtonProps { + label?: string; + variant?: ComponentProps["variant"]; + size?: ComponentProps["size"]; + className?: string; +} + +export function RestartServerButton({ + label = "Restart Server", + variant = "outline", + size = "sm", + className, +}: RestartServerButtonProps = {}) { const [showConfirm, setShowConfirm] = useState(false); async function handleRestart() { @@ -20,9 +32,14 @@ export function RestartServerButton() { return ( <> - `${Math.round(bytes / MB)} MB`; + +/** + * What actually happens to each uploaded asset, mirrored from + * `internal/branding/assets.go` (per-kind caps and processing) and + * `internal/imageutil/imageutil.go` (width cap vs. square center-crop). Every + * number the UI quotes is written once here so the guidance cannot drift away + * from the pipeline one slot at a time. The same numbers are documented for API + * clients in `docs/admin-api.md`. + * + * `defaultUrl` is the bundled asset served while a slot is empty — the same + * files `PrairieBrand` and the favicon link fall back to. The login background has + * none: the auth pages just keep their theme gradient. + */ +export const BRANDING_ASSET_SPECS: Record< + BrandingAssetKind, + BrandingAssetSpec +> = { + wordmark: { + storedPx: WORDMARK_PX, + maxUploadBytes: WORDMARK_MAX_BYTES, + guidance: `Wide artwork, ${WORDMARK_PX}px or wider. Stored as WebP capped at ${WORDMARK_PX}px wide; narrower images are not enlarged. PNG, JPEG, or WebP up to ${megabytes(WORDMARK_MAX_BYTES)}.`, + defaultUrl: "/prairie-wordmark-sidebar.png", + emptyCaption: "Default", + }, + // Light variants share their base kind's pipeline. With no upload they + // inherit the main asset instead of using a separate bundled default. + wordmark_light: { + storedPx: WORDMARK_PX, + maxUploadBytes: WORDMARK_MAX_BYTES, + guidance: `Wide artwork, ${WORDMARK_PX}px or wider. Stored as WebP capped at ${WORDMARK_PX}px wide; narrower images are not enlarged. PNG, JPEG, or WebP up to ${megabytes(WORDMARK_MAX_BYTES)}.`, + defaultUrl: null, + emptyCaption: "Falls back to the main logo", + }, + mark: { + storedPx: MARK_PX, + maxUploadBytes: MARK_MAX_BYTES, + guidance: `Square artwork, ${MARK_PX}×${MARK_PX} or larger. Anything else is center-cropped to a square, then stored as WebP at exactly ${MARK_PX}×${MARK_PX} — smaller art is upscaled. PNG, JPEG, or WebP up to ${megabytes(MARK_MAX_BYTES)}.`, + defaultUrl: "/prairie-icon-1024.png", + emptyCaption: "Default", + }, + mark_light: { + storedPx: MARK_PX, + maxUploadBytes: MARK_MAX_BYTES, + guidance: `Square artwork, ${MARK_PX}×${MARK_PX} or larger. Anything else is center-cropped to a square, then stored as WebP at exactly ${MARK_PX}×${MARK_PX} — smaller art is upscaled. PNG, JPEG, or WebP up to ${megabytes(MARK_MAX_BYTES)}.`, + defaultUrl: null, + emptyCaption: "Falls back to the main icon", + }, + favicon: { + storedPx: null, + maxUploadBytes: FAVICON_MAX_BYTES, + guidance: `Square PNG, ICO, or SVG up to ${megabytes(FAVICON_MAX_BYTES)}. Stored exactly as uploaded — never resized or re-encoded.`, + defaultUrl: "/favicon.ico", + emptyCaption: "Default", + }, + login_bg: { + storedPx: LOGIN_BG_PX, + maxUploadBytes: LOGIN_BG_MAX_BYTES, + guidance: `Wide photo, ${LOGIN_BG_PX}px or wider. Stored as WebP capped at ${LOGIN_BG_PX}px wide and shown cover-cropped, so keep the subject centered. PNG, JPEG, or WebP up to ${megabytes(LOGIN_BG_MAX_BYTES)}.`, + defaultUrl: null, + emptyCaption: "Theme gradient", + }, +}; diff --git a/web/src/components/admin/libraries/LibraryFormSections.tsx b/web/src/components/admin/libraries/LibraryFormSections.tsx index b0f160f17..5bf2d93a1 100644 --- a/web/src/components/admin/libraries/LibraryFormSections.tsx +++ b/web/src/components/admin/libraries/LibraryFormSections.tsx @@ -328,7 +328,7 @@ export function MetadataFields({ form }: { form: LibraryFormController }) {

When providers have no translation for this library's language, translate descriptions with AI after each refresh. Requires AI - description translation in Admin Settings → AI Services. + description translation in Admin Settings → AI.

/^mb-/.test(name)); +} + describe("CardOverlays", () => { beforeEach(() => vi.stubGlobal("CSS", { supports: () => true })); afterEach(() => { @@ -332,6 +337,9 @@ describe("CardOverlays", () => { it("scales legacy browsers from the measured poster width", () => { let callback: ResizeObserverCallback | undefined; const disconnect = vi.fn(); + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + borderTopLeftRadius: "8px", + } as CSSStyleDeclaration); vi.stubGlobal("CSS", { supports: () => false }); vi.stubGlobal( "ResizeObserver", @@ -447,48 +455,123 @@ describe("CardOverlays", () => { expect(vibrant?.style.boxShadow).toBe("0px 1px 2px 0px rgb(0 0 0 / 0.25)"); }); - it("lifts bottom-corner badges above persistent card actions", () => { + it("anchors bottom-corner badges flush in the corners like the top row", () => { + // Card quick actions cover bottom badges by design, so the bottom row + // reserves no clearance for them and insets exactly like the top row. const prefs = prefsWithOnly("content_rating"); + const bottomEdge = (variant?: "wide") => { + prefs.items.content_rating = { + ...prefs.items.content_rating, + position: "bottom-left", + }; + const left = render( + , + ).container; + prefs.items.content_rating = { + ...prefs.items.content_rating, + position: "bottom-right", + }; + const right = render( + , + ).container; + return { + leftStack: left.querySelector( + '[data-overlay-edge="bottom"] > div.items-start', + ), + rightStack: right.querySelector( + '[data-overlay-edge="bottom"] > div.items-end', + ), + row: left.querySelector('[data-overlay-edge="bottom"]'), + }; + }; + + for (const variant of [undefined, "wide" as const]) { + const { leftStack, rightStack, row } = bottomEdge(variant); + expect(leftStack).toBeTruthy(); + expect(rightStack).toBeTruthy(); + for (const node of [leftStack, rightStack, row]) { + expect( + bottomMarginClasses(node), + `${variant ?? "poster"} bottom edge`, + ).toEqual([]); + } + } + + prefs.items.content_rating = { + ...prefs.items.content_rating, + position: "top-left", + }; + const top = render( + , + ).container.querySelector('[data-overlay-edge="top"]'); prefs.items.content_rating = { ...prefs.items.content_rating, position: "bottom-left", }; - const left = render( + const bottom = render( , - ).container; - expect( - left.querySelector("div.bottom-2 > div.items-start.mb-10"), - ).toBeTruthy(); - expect(left.querySelector("div.bottom-2")?.className).toContain("z-10"); + ).container.querySelector('[data-overlay-edge="bottom"]'); + expect(top?.style.top).toBe(posterLength(8)); + expect(bottom?.style.bottom).toBe(top?.style.top); + }); + it("lifts the bottom row only when the host draws a watch-progress bar", () => { + // The bar occupies the same edge strip, so a flush badge would cut it. + const prefs = prefsWithOnly("content_rating"); prefs.items.content_rating = { ...prefs.items.content_rating, - position: "bottom-right", + position: "bottom-left", }; - const poster = render( + const flush = render( , - ).container; - expect( - poster.querySelector("div.bottom-2 > div.items-end.mb-10"), - ).toBeTruthy(); + ).container.querySelector('[data-overlay-edge="bottom"]'); + expect(bottomMarginClasses(flush)).toEqual([]); - const wide = render( - , - ).container; - expect( - wide.querySelector("div.bottom-2 > div.items-end.mb-12"), - ).toBeTruthy(); + const lifted = render( + , + ).container.querySelector('[data-overlay-edge="bottom"]'); + expect(bottomMarginClasses(lifted)).toEqual(["mb-2"]); + }); + it("keeps the badge layer beneath card actions and non-interactive", () => { + // MediaItemMenu renders its quick actions at z-20 in the card wrapper's + // stacking context (see MediaItemMenu.test.tsx), so this z-10 layer paints + // beneath them; pointer-events-none keeps a covered badge from swallowing + // a click aimed at the action on top of it. + const prefs = prefsWithOnly("content_rating"); prefs.items.content_rating = { ...prefs.items.content_rating, position: "bottom-left", }; - const wideLeft = render( - , - ).container; + const { container } = render( + , + ); + + for (const selector of [ + '[data-card-overlays="poster"]', + '[data-overlay-edge="bottom"]', + ]) { + const node = container.querySelector(selector); + expect(node, selector).toBeTruthy(); + expect(node?.classList.contains("z-10"), selector).toBe(true); + expect(node?.classList.contains("pointer-events-none"), selector).toBe( + true, + ); + } + // Nothing inside the layer may re-enable hit testing. + expect(container.querySelectorAll(".pointer-events-auto").length).toBe(0); expect( - wideLeft.querySelector("div.bottom-2 > div.items-start.mb-12"), - ).toBeTruthy(); + container.querySelector("span.inline-flex")?.style + .pointerEvents, + ).toBe(""); }); it("renders nothing when no enabled overlay has data", () => { diff --git a/web/src/components/overlays/CardOverlays.tsx b/web/src/components/overlays/CardOverlays.tsx index fc062b127..051c958d1 100644 --- a/web/src/components/overlays/CardOverlays.tsx +++ b/web/src/components/overlays/CardOverlays.tsx @@ -6,6 +6,7 @@ import { getPreset, orderedOverlaysForPosition, } from "@/lib/overlays"; +import { cn } from "@/lib/utils"; import type { CardOverlayPrefs, OverlayData, @@ -183,6 +184,13 @@ interface CardOverlaysProps { data: OverlayData; prefs: CardOverlayPrefs; variant?: "poster" | "wide"; + /** + * Set by hosts that draw a watch-progress bar along the bottom of the + * artwork (ContinueWatchingCard, EpisodeRow, SeasonEpisodeGrid). The bar + * occupies the same edge strip as the bottom badge row, so the row rises + * just clear of it; with no bar the badges stay flush in the corners. + */ + hasProgressBar?: boolean; } interface ResolvedBadge { @@ -231,13 +239,11 @@ function BadgeStack({ align, preset, scalingMode, - extraClass = "", }: { badges: ResolvedBadge[]; align: "start" | "end"; preset: OverlayPreset; scalingMode: ScalingMode; - extraClass?: string; }) { const length = (pixels: number, legacyVariable: string) => overlayLength(pixels, scalingMode, legacyVariable); @@ -245,7 +251,7 @@ function BadgeStack({ return (
{badges.map((badge) => { @@ -323,10 +329,21 @@ function BadgeStack({ // Each card edge renders as ONE flex row holding the left and right corner // stacks. Sharing a row lets flexbox divide the card width between opposing // corners (min-w-0 + truncate), so wide badges shrink instead of overlapping. +// +// Stacking contract with the card quick actions (watched/favorite bottom-left, +// more-menu bottom-right; web/src/components/MediaItemMenu.tsx): this whole +// layer paints at z-10 inside the artwork box, the action wrappers paint at +// z-20 as siblings of the artwork link in the same stacking context, so the +// actions always cover overlay badges. That is by design — badges sit flush in +// all four corners and the actions (hover-revealed on fine pointers, +// persistent on coarse ones) render on top of them. The layer is +// pointer-events-none end to end, so a badge can never swallow a click aimed +// at an action underneath it. export default function CardOverlays({ data, prefs, variant = "poster", + hasProgressBar = false, }: CardOverlaysProps) { const preset = getPreset(prefs.preset); const resolve = (pos: OverlayPosition): ResolvedBadge[] => @@ -379,8 +396,7 @@ export default function CardOverlays({ const measured = Number.parseFloat( getComputedStyle(badge).borderTopLeftRadius, ); - if (Number.isFinite(measured) && measured > 0) - baseBorderRadius = measured; + if (Number.isFinite(measured)) baseBorderRadius = measured; } } @@ -438,9 +454,17 @@ export default function CardOverlays({
)} {(bottomLeft.length > 0 || bottomRight.length > 0) && ( + /* Bottom badges anchor flush in the corners with the same inset as the + top row; card actions cover them by design (see above). The single + exception is a watch-progress bar, which lives in this same edge + strip and would be cut in half by a flush badge, so hosts that draw + one lift the row by one edge step. */
- {/* Bottom badges reserve corner clearance because card actions stay - visible on touch and other devices without a fine hover pointer. */}
)} diff --git a/web/src/components/overlays/OverlayPreviewCard.tsx b/web/src/components/overlays/OverlayPreviewCard.tsx index 41f47ba80..62ae56df4 100644 --- a/web/src/components/overlays/OverlayPreviewCard.tsx +++ b/web/src/components/overlays/OverlayPreviewCard.tsx @@ -5,9 +5,12 @@ import { type CardOverlayPrefs, } from "@/lib/overlays"; +/** Which sample item the preview stands in for. Picked by . */ +export type OverlayPreviewVariant = "movie" | "show"; + interface OverlayPreviewCardProps { prefs: CardOverlayPrefs; - variant?: "movie" | "show"; + variant?: OverlayPreviewVariant; size?: "sm" | "md"; showPosterOverlays?: boolean; } diff --git a/web/src/components/overlays/OverlayPreviewVariantToggle.tsx b/web/src/components/overlays/OverlayPreviewVariantToggle.tsx new file mode 100644 index 000000000..84c7c83e6 --- /dev/null +++ b/web/src/components/overlays/OverlayPreviewVariantToggle.tsx @@ -0,0 +1,47 @@ +import { cn } from "@/lib/utils"; +import type { OverlayPreviewVariant } from "./OverlayPreviewCard"; + +interface OverlayPreviewVariantToggleProps { + value: OverlayPreviewVariant; + onChange: (value: OverlayPreviewVariant) => void; + className?: string; +} + +const VARIANTS: readonly OverlayPreviewVariant[] = ["movie", "show"]; + +/** + * Pill pair that picks which sample data renders. Shared + * by the user Card Overlays page and the admin defaults editor so both can + * preview show-only overlays (network, show status) while editing. The choice + * is local view state on both surfaces and is deliberately never persisted. + */ +export function OverlayPreviewVariantToggle({ + value, + onChange, + className, +}: OverlayPreviewVariantToggleProps) { + return ( +
+ {VARIANTS.map((variant) => ( + + ))} +
+ ); +} diff --git a/web/src/components/realtimeCatalogInvalidation.test.ts b/web/src/components/realtimeCatalogInvalidation.test.ts new file mode 100644 index 000000000..9bbfa248a --- /dev/null +++ b/web/src/components/realtimeCatalogInvalidation.test.ts @@ -0,0 +1,222 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + catalogKeys, + mediaSurfaceKeys, + sectionKeys, +} from "@/hooks/queries/keys"; +import { + createCatalogInvalidationScheduler, + PROGRESS_HOME_REFRESH_WINDOW_MS, + scheduleProgressHomeRefresh, + userStateChangeAffectsSectionMembership, +} from "./realtimeCatalogInvalidation"; + +const WINDOW_MS = 2_000; + +function catalogListKey(libraryId: number) { + return catalogKeys.list({ + source: "section", + scope: "library", + section_id: "all", + library_id: libraryId, + limit: 60, + offset: 0, + }); +} + +/** Seeds one cached query per library so invalidation scope is observable. */ +function seedLibraries(queryClient: QueryClient, libraryIds: number[]) { + for (const libraryId of libraryIds) { + queryClient.setQueryData(catalogListKey(libraryId), { items: [] }); + } +} + +function invalidatedLibraries(queryClient: QueryClient, libraryIds: number[]) { + return libraryIds.filter( + (libraryId) => + queryClient.getQueryState(catalogListKey(libraryId))?.isInvalidated, + ); +} + +describe("createCatalogInvalidationScheduler", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("invalidates the first event immediately", async () => { + const queryClient = new QueryClient(); + seedLibraries(queryClient, [1, 3]); + const scheduler = createCatalogInvalidationScheduler( + queryClient, + WINDOW_MS, + ); + + scheduler.schedule({ libraryId: 3, allowDashboardRefetch: false }); + await vi.advanceTimersByTimeAsync(0); + + expect(invalidatedLibraries(queryClient, [1, 3])).toEqual([3]); + }); + + it("coalesces a burst into a single trailing sweep", async () => { + const queryClient = new QueryClient(); + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); + const scheduler = createCatalogInvalidationScheduler( + queryClient, + WINDOW_MS, + ); + + scheduler.schedule({ libraryId: 3, allowDashboardRefetch: false }); + await vi.advanceTimersByTimeAsync(0); + const afterLeadingEdge = invalidateQueries.mock.calls.length; + + for (let i = 0; i < 50; i += 1) { + scheduler.schedule({ + itemId: `item-${i}`, + libraryId: 3, + allowDashboardRefetch: false, + }); + } + await vi.advanceTimersByTimeAsync(WINDOW_MS - 1); + + expect(invalidateQueries.mock.calls.length).toBe(afterLeadingEdge); + + await vi.advanceTimersByTimeAsync(1); + + // Exactly one more sweep for all 50 events, not 50 sweeps. + expect(invalidateQueries.mock.calls.length).toBe(afterLeadingEdge * 2); + }); + + it("widens to an unscoped sweep when a window spans several libraries", async () => { + const queryClient = new QueryClient(); + seedLibraries(queryClient, [1, 3]); + const scheduler = createCatalogInvalidationScheduler( + queryClient, + WINDOW_MS, + ); + + // Leading edge consumes the first event; the rest share one window. + scheduler.schedule({ libraryId: 3, allowDashboardRefetch: false }); + await vi.advanceTimersByTimeAsync(0); + seedLibraries(queryClient, [1, 3]); + + scheduler.schedule({ libraryId: 3, allowDashboardRefetch: false }); + scheduler.schedule({ libraryId: 1, allowDashboardRefetch: false }); + await vi.advanceTimersByTimeAsync(WINDOW_MS); + + expect(invalidatedLibraries(queryClient, [1, 3])).toEqual([1, 3]); + }); + + it("still invalidates the touched library's own sections on a scoped sweep", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(sectionKeys.libraryLayout(3), { sections: [] }); + const scheduler = createCatalogInvalidationScheduler( + queryClient, + WINDOW_MS, + ); + + scheduler.schedule({ libraryId: 3, allowDashboardRefetch: false }); + await vi.advanceTimersByTimeAsync(0); + + expect( + queryClient.getQueryState(sectionKeys.libraryLayout(3))?.isInvalidated, + ).toBe(true); + }); + + it("drops queued work on cancel", async () => { + const queryClient = new QueryClient(); + const scheduler = createCatalogInvalidationScheduler( + queryClient, + WINDOW_MS, + ); + + scheduler.schedule({ libraryId: 3, allowDashboardRefetch: false }); + await vi.advanceTimersByTimeAsync(0); + seedLibraries(queryClient, [3]); + + scheduler.schedule({ libraryId: 3, allowDashboardRefetch: false }); + scheduler.cancel(); + await vi.advanceTimersByTimeAsync(WINDOW_MS * 2); + + expect(invalidatedLibraries(queryClient, [3])).toEqual([]); + + // The window timer is gone too, so the next event leads again. + scheduler.schedule({ libraryId: 3, allowDashboardRefetch: false }); + await vi.advanceTimersByTimeAsync(0); + + expect(invalidatedLibraries(queryClient, [3])).toEqual([3]); + }); + + it("marks home section data stale (without refetching) on a library-scoped sweep", async () => { + const queryClient = new QueryClient(); + const homeItemsKey = sectionKeys.homeItems("recently-added"); + queryClient.setQueryData(sectionKeys.homeLayout(), { sections: [] }); + queryClient.setQueryData(homeItemsKey, { items: [] }); + const scheduler = createCatalogInvalidationScheduler( + queryClient, + WINDOW_MS, + ); + + scheduler.schedule({ libraryId: 3, allowDashboardRefetch: false }); + await vi.advanceTimersByTimeAsync(0); + + // Stale, so the throttled home queue reset fetches real data instead of + // re-rendering the fresh-but-outdated cache… + expect( + queryClient.getQueryState(sectionKeys.homeLayout())?.isInvalidated, + ).toBe(true); + expect(queryClient.getQueryState(homeItemsKey)?.isInvalidated).toBe(true); + // …but the sweep itself never refetches them (no observers here, and the + // invalidation is refetchType "none"). + expect(queryClient.isFetching()).toBe(0); + }); +}); + +describe("userStateChangeAffectsSectionMembership", () => { + it("ignores progress ticks and accepts every membership change", () => { + expect(userStateChangeAffectsSectionMembership("progress")).toBe(false); + for (const change of [ + "favorite", + "watchlist", + "history", + "watched", + "home_dismissal", + ]) { + expect(userStateChangeAffectsSectionMembership(change)).toBe(true); + } + }); + + it("treats an unknown change as membership-affecting", () => { + expect(userStateChangeAffectsSectionMembership(undefined)).toBe(true); + }); +}); + +describe("scheduleProgressHomeRefresh", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { + // Drain any armed trailing refresh so module-level timer state cannot + // leak into the next test. + vi.runAllTimers(); + vi.useRealTimers(); + }); + + it("coalesces a stream of progress ticks into one trailing refresh", () => { + const queryClient = new QueryClient(); + const signal = () => + queryClient.getQueryData(mediaSurfaceKeys.refreshSignal()) ?? 0; + + for (let i = 0; i < 10; i += 1) { + scheduleProgressHomeRefresh(queryClient); + vi.advanceTimersByTime(1_000); + } + // Ticks alone never reset the home load queue… + expect(signal()).toBe(0); + + vi.advanceTimersByTime(PROGRESS_HOME_REFRESH_WINDOW_MS); + // …but an open home catches the playback within one window. + expect(signal()).toBe(1); + + scheduleProgressHomeRefresh(queryClient); + vi.advanceTimersByTime(PROGRESS_HOME_REFRESH_WINDOW_MS); + expect(signal()).toBe(2); + }); +}); diff --git a/web/src/components/realtimeCatalogInvalidation.ts b/web/src/components/realtimeCatalogInvalidation.ts index 9721d3427..441fa2402 100644 --- a/web/src/components/realtimeCatalogInvalidation.ts +++ b/web/src/components/realtimeCatalogInvalidation.ts @@ -1,16 +1,18 @@ import type { QueryClient } from "@tanstack/react-query"; -import { adminKeys, libraryKeys } from "@/hooks/queries/keys"; +import { adminKeys, libraryKeys, sectionKeys } from "@/hooks/queries/keys"; import { invalidateMediaSurfaceQueries } from "@/hooks/queries/mediaSurfaceRefresh"; import { bumpHomeRefreshSignal } from "@/pages/homeSurfaceRefresh"; +export interface CatalogInvalidationOptions { + itemId?: string; + libraryId?: number; + allowDashboardRefetch: boolean; + includeLibraryLists?: boolean; +} + export function invalidateCatalogState( queryClient: QueryClient, - options: { - itemId?: string; - libraryId?: number; - allowDashboardRefetch: boolean; - includeLibraryLists?: boolean; - }, + options: CatalogInvalidationOptions, ) { const { itemId, @@ -18,6 +20,18 @@ export function invalidateCatalogState( allowDashboardRefetch, includeLibraryLists = true, } = options; + // Library-scoped sweeps leave home section queries out (see + // activeSectionQueryMatchesLibrary) so a scan cannot storm them — but the + // refresh-signal bump below still reloads Home's rows through fetchQuery, + // which would happily serve the fresh-but-outdated cache. Mark home data + // stale without refetching, so only the (already throttled) queue reset + // fetches, and it fetches real data. + if (libraryId !== undefined) { + void queryClient.invalidateQueries({ + queryKey: sectionKeys.home(), + refetchType: "none", + }); + } void invalidateMediaSurfaceQueries(queryClient, { itemId, libraryId }).then( () => { bumpHomeRefreshSignal(queryClient); @@ -38,3 +52,148 @@ export function invalidateCatalogState( refetchType: allowDashboardRefetch ? "active" : "none", }); } + +/** + * A scan emits one catalog event per file it touches. Running the full + * invalidation sweep for each of them refetches every open surface hundreds of + * times a minute for data that is still changing, so events are coalesced into + * one sweep per window. + */ +export const CATALOG_INVALIDATION_WINDOW_MS = 2_000; + +interface PendingCatalogInvalidation { + itemIds: Set; + libraryIds: Set; + hasUnscopedEvent: boolean; + allowDashboardRefetch: boolean; + includeLibraryLists: boolean; +} + +export interface CatalogInvalidationScheduler { + /** Invalidates now if the window is open, otherwise folds into its flush. */ + schedule: (options: CatalogInvalidationOptions) => void; + /** Drops anything still queued. Call on teardown. */ + cancel: () => void; +} + +/** + * Throttles catalog invalidation to one sweep per window, with the first event + * after an idle period applied immediately so an isolated change (a single + * metadata edit) still lands without delay. + * + * Merging widens rather than narrows: a window that saw two libraries, or any + * event with no library at all, flushes unscoped, and a window that saw several + * items drops the per-item keys — the broad `items`/`catalog` invalidations + * inside `invalidateMediaSurfaceQueries` already cover every item detail. + */ +export function createCatalogInvalidationScheduler( + queryClient: QueryClient, + windowMs: number = CATALOG_INVALIDATION_WINDOW_MS, +): CatalogInvalidationScheduler { + let windowTimer: number | undefined; + let pending: PendingCatalogInvalidation | null = null; + + const flush = () => { + const batch = pending; + pending = null; + windowTimer = window.setTimeout(onWindowClosed, windowMs); + if (batch) { + invalidateCatalogState(queryClient, resolveBatch(batch)); + } + }; + + const onWindowClosed = () => { + windowTimer = undefined; + if (pending) flush(); + }; + + return { + schedule(options) { + pending = mergeCatalogInvalidation(pending, options); + if (windowTimer === undefined) flush(); + }, + cancel() { + if (windowTimer !== undefined) window.clearTimeout(windowTimer); + windowTimer = undefined; + pending = null; + }, + }; +} + +function mergeCatalogInvalidation( + pending: PendingCatalogInvalidation | null, + options: CatalogInvalidationOptions, +): PendingCatalogInvalidation { + const next: PendingCatalogInvalidation = pending ?? { + itemIds: new Set(), + libraryIds: new Set(), + hasUnscopedEvent: false, + allowDashboardRefetch: false, + includeLibraryLists: false, + }; + + if (options.itemId) next.itemIds.add(options.itemId); + if (options.libraryId === undefined) { + next.hasUnscopedEvent = true; + } else { + next.libraryIds.add(options.libraryId); + } + next.allowDashboardRefetch = + next.allowDashboardRefetch || options.allowDashboardRefetch; + next.includeLibraryLists = + next.includeLibraryLists || (options.includeLibraryLists ?? true); + + return next; +} + +function resolveBatch( + batch: PendingCatalogInvalidation, +): CatalogInvalidationOptions { + const [onlyItemId] = batch.itemIds; + const [onlyLibraryId] = batch.libraryIds; + return { + itemId: batch.itemIds.size === 1 ? onlyItemId : undefined, + libraryId: + !batch.hasUnscopedEvent && batch.libraryIds.size === 1 + ? onlyLibraryId + : undefined, + allowDashboardRefetch: batch.allowDashboardRefetch, + includeLibraryLists: batch.includeLibraryLists, + }; +} + +/** + * Whether a `user_state` change can move an item in or out of a home section. + * + * Progress ticks arrive every few seconds throughout playback and only move a + * position, so they must not reset the home load queue per event; they refresh + * through `scheduleProgressHomeRefresh` instead. + */ +export function userStateChangeAffectsSectionMembership( + change: string | undefined, +): boolean { + return change !== "progress"; +} + +/** + * One trailing home refresh per window for progress ticks. Another client's + * playback still has to reach an OPEN home page: the first tick moves an item + * into Continue Watching and the bar itself should advance — but Home renders + * from its own loaded-section state, which only the refresh signal reloads. + * A per-tick bump would reset the load queue every few seconds for the whole + * playback; one bump per window keeps home current without that storm. + */ +export const PROGRESS_HOME_REFRESH_WINDOW_MS = 30_000; + +let progressHomeRefreshTimer: number | undefined; + +export function scheduleProgressHomeRefresh( + queryClient: QueryClient, + windowMs: number = PROGRESS_HOME_REFRESH_WINDOW_MS, +) { + if (progressHomeRefreshTimer !== undefined) return; + progressHomeRefreshTimer = window.setTimeout(() => { + progressHomeRefreshTimer = undefined; + bumpHomeRefreshSignal(queryClient); + }, windowMs); +} diff --git a/web/src/components/settings/AdvancedSection.test.tsx b/web/src/components/settings/AdvancedSection.test.tsx new file mode 100644 index 000000000..c38c22968 --- /dev/null +++ b/web/src/components/settings/AdvancedSection.test.tsx @@ -0,0 +1,162 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { AdvancedSection } from "@/components/settings/AdvancedSection"; + +function renderSection( + props: Partial[0]> = {}, +) { + return render( + +
ffmpeg path
+
, + ); +} + +describe("AdvancedSection", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("starts collapsed and labels the disclosure with the setting count", () => { + renderSection(); + + const toggle = screen.getByRole("button", { + name: /Advanced · 3 settings/, + }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByText("ffmpeg path")).not.toBeInTheDocument(); + }); + + it("uses the singular form for a single setting", () => { + renderSection({ count: 1 }); + + expect( + screen.getByRole("button", { name: /Advanced · 1 setting$/ }), + ).toBeInTheDocument(); + }); + + it("shows the count as a bare number and spells it out for screen readers", () => { + renderSection({ count: 9 }); + + expect( + screen.getByRole("button", { name: "Advanced · 9 settings" }), + ).toBeInTheDocument(); + expect(screen.getByText("9")).toBeInTheDocument(); + }); + + it("persists the open state under the section id", async () => { + const user = userEvent.setup(); + const { unmount } = renderSection(); + + await user.click(screen.getByRole("button", { name: /Advanced/ })); + expect(screen.getByText("ffmpeg path")).toBeInTheDocument(); + expect( + localStorage.getItem("silo.admin.advanced.playback.transcoding"), + ).toBe("true"); + + unmount(); + renderSection(); + expect(screen.getByText("ffmpeg path")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /Advanced/ })); + expect( + localStorage.getItem("silo.admin.advanced.playback.transcoding"), + ).toBe("false"); + expect(screen.queryByText("ffmpeg path")).not.toBeInTheDocument(); + }); + + it("does not inherit another section's persisted state", () => { + localStorage.setItem("silo.admin.advanced.playback.transcoding", "true"); + renderSection({ id: "downloads" }); + + expect(screen.queryByText("ffmpeg path")).not.toBeInTheDocument(); + }); + + it("honours defaultOpen only until a choice is persisted", async () => { + const user = userEvent.setup(); + const { unmount } = renderSection({ defaultOpen: true }); + expect(screen.getByText("ffmpeg path")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /Advanced/ })); + unmount(); + + renderSection({ defaultOpen: true }); + expect(screen.queryByText("ffmpeg path")).not.toBeInTheDocument(); + }); + + it("opens automatically while forceOpen is set", () => { + const { rerender } = render( + +
bandwidth
+
, + ); + expect(screen.queryByText("bandwidth")).not.toBeInTheDocument(); + + rerender( + +
bandwidth
+
, + ); + expect(screen.getByText("bandwidth")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Advanced/ })).toHaveAttribute( + "aria-expanded", + "true", + ); + }); + + it("opens on the first render when forceOpen is already set", () => { + render( + +
bandwidth
+
, + ); + + expect(screen.getByText("bandwidth")).toBeInTheDocument(); + }); + + it("re-expands when a new reason to force it open arrives after a manual collapse", async () => { + const user = userEvent.setup(); + const { rerender } = render( + +
bandwidth
+
, + ); + + await user.click(screen.getByRole("button", { name: /Advanced/ })); + expect(screen.queryByText("bandwidth")).not.toBeInTheDocument(); + + // The reason clears (the field was saved), then a different field inside + // goes dirty. The stale manual collapse must not keep it hidden. + rerender( + +
bandwidth
+
, + ); + expect(screen.queryByText("bandwidth")).not.toBeInTheDocument(); + + rerender( + +
bandwidth
+
, + ); + expect(screen.getByText("bandwidth")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Advanced/ })).toHaveAttribute( + "aria-expanded", + "true", + ); + }); + + it("lets an auto-expanded section be collapsed again", async () => { + const user = userEvent.setup(); + render( + +
bandwidth
+
, + ); + + await user.click(screen.getByRole("button", { name: /Advanced/ })); + expect(screen.queryByText("bandwidth")).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/settings/AdvancedSection.tsx b/web/src/components/settings/AdvancedSection.tsx new file mode 100644 index 000000000..53c615ea3 --- /dev/null +++ b/web/src/components/settings/AdvancedSection.tsx @@ -0,0 +1,143 @@ +import { useState, type ReactNode } from "react"; +import { ChevronRight } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import "@/styles/admin-settings.css"; + +const STORAGE_PREFIX = "silo.admin.advanced."; + +function storageKey(id: string) { + return `${STORAGE_PREFIX}${id}`; +} + +function readPersisted(id: string): boolean | null { + try { + const raw = localStorage.getItem(storageKey(id)); + if (raw === "true") return true; + if (raw === "false") return false; + return null; + } catch { + return null; + } +} + +function writePersisted(id: string, open: boolean): void { + try { + localStorage.setItem(storageKey(id), open ? "true" : "false"); + } catch { + // Storage full or unavailable: the disclosure still works this session. + } +} + +export interface AdvancedSectionProps { + /** Stable id for the persisted open state, e.g. `playback.transcoding`. */ + id: string; + /** Number of settings inside, rendered as a muted count on the right. */ + count?: number; + title?: string; + /** Open state used when nothing is persisted yet. */ + defaultOpen?: boolean; + /** + * Forces the section open regardless of the persisted state — pass the + * section's dirty/invalid/search-match state so a hidden field can never be + * the reason a save bar refuses to save. + */ + forceOpen?: boolean; + children: ReactNode; +} + +/** + * The single disclosure primitive for advanced admin settings: an inline row at + * the end of a group rather than a nested card. Collapsed by default, remembers + * the admin's choice per section in localStorage, and auto-expands while + * `forceOpen` is set. + */ +export function AdvancedSection({ + id, + count, + title = "Advanced", + defaultOpen = false, + forceOpen = false, + children, +}: AdvancedSectionProps) { + // Persisted choice, read once: a section's id is fixed for the life of the + // instance (give the component a `key` if a caller ever swaps ids). + const [persistedOpen, setPersistedOpen] = useState( + () => readPersisted(id) ?? defaultOpen, + ); + // Explicit toggle this session, which also wins over `forceOpen` so an + // auto-expanded section can still be collapsed. + const [override, setOverride] = useState(null); + const [wasForcedOpen, setWasForcedOpen] = useState(forceOpen); + + // A manual collapse only outranks the *current* reason to force the section + // open. When a new one arrives (a field inside just went dirty or invalid, or + // a search started matching), drop the override so the save bar can never + // block on a field the admin cannot see. Adjusting state during render is + // cheaper than an effect: React re-renders before committing. + if (forceOpen !== wasForcedOpen) { + setWasForcedOpen(forceOpen); + if (forceOpen) setOverride(null); + } + + const open = override ?? (persistedOpen || forceOpen); + + function toggle() { + const next = !open; + setOverride(next); + setPersistedOpen(next); + writePersisted(id, next); + } + + // The number reads as a bare count on screen; the accessible name spells out + // what it counts, since it is visually pushed to the right of the row. + const accessibleLabel = + typeof count === "number" + ? `${title} · ${count} setting${count === 1 ? "" : "s"}` + : title; + + return ( +
+ + {open ? ( +
+ {children} +
+ ) : null} +
+ ); +} diff --git a/web/src/components/settings/LimitField.test.tsx b/web/src/components/settings/LimitField.test.tsx new file mode 100644 index 000000000..289c5b756 --- /dev/null +++ b/web/src/components/settings/LimitField.test.tsx @@ -0,0 +1,167 @@ +import { useState } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { LimitField } from "@/components/settings/LimitField"; + +function Harness({ + initial, + onChange, + unlimitedValue, +}: { + initial: string; + onChange?: (value: string) => void; + unlimitedValue?: string; +}) { + const [value, setValue] = useState(initial); + return ( + { + setValue(next); + onChange?.(next); + }} + /> + ); +} + +describe("LimitField", () => { + it("reads the sentinel as unlimited and hides it from the input", () => { + render(); + + expect(screen.getByRole("checkbox", { name: "Unlimited" })).toBeChecked(); + const input = screen.getByLabelText("Per-user bandwidth"); + expect(input).toBeDisabled(); + expect(input).toHaveValue(null); + }); + + it("writes the sentinel when Unlimited is checked", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + const checkbox = screen.getByRole("checkbox", { name: "Unlimited" }); + expect(checkbox).not.toBeChecked(); + + await user.click(checkbox); + expect(onChange).toHaveBeenLastCalledWith("0"); + expect(screen.getByLabelText("Per-user bandwidth")).toBeDisabled(); + }); + + it("restores the previous limit when Unlimited is unchecked", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + const checkbox = screen.getByRole("checkbox", { name: "Unlimited" }); + await user.click(checkbox); + await user.click(checkbox); + + expect(onChange).toHaveBeenLastCalledWith("50"); + expect(screen.getByLabelText("Per-user bandwidth")).toHaveValue(50); + }); + + it("falls back to an empty limit when unlimited was the saved value", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole("checkbox", { name: "Unlimited" })); + expect(onChange).toHaveBeenLastCalledWith(""); + expect(screen.getByLabelText("Per-user bandwidth")).toBeEnabled(); + }); + + it("supports a non-zero unlimited sentinel", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + expect( + screen.getByRole("checkbox", { name: "Unlimited" }), + ).not.toBeChecked(); + await user.click(screen.getByRole("checkbox", { name: "Unlimited" })); + expect(onChange).toHaveBeenLastCalledWith("-1"); + expect(screen.getByRole("checkbox", { name: "Unlimited" })).toBeChecked(); + }); + + it("passes typed limits straight through", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.type(screen.getByLabelText("Per-user bandwidth"), "25"); + expect(onChange).toHaveBeenLastCalledWith("25"); + }); +}); + +function ScaledHarness({ + initial, + onChange, +}: { + initial: string; + onChange?: (v: string) => void; +}) { + const [value, setValue] = useState(initial); + return ( + { + setValue(next); + onChange?.(next); + }} + /> + ); +} + +describe("LimitField scaled units", () => { + it("shows a stored byte value in the display unit", () => { + render(); + + expect(screen.getByLabelText("Prepared file storage budget")).toHaveValue( + 53.687, + ); + }); + + it("stores what was typed in the underlying unit", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.type( + screen.getByLabelText("Prepared file storage budget"), + "50", + ); + expect(onChange).toHaveBeenLastCalledWith("50000000000"); + }); + + it("keeps a fractional entry typeable instead of rewriting it mid-keystroke", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.type( + screen.getByLabelText("Prepared file storage budget"), + "1.5", + ); + expect(onChange).toHaveBeenLastCalledWith("1500000000"); + }); + + it("writes the unscaled sentinel for unlimited", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole("checkbox", { name: "Unlimited" })); + expect(onChange).toHaveBeenLastCalledWith("0"); + expect(screen.getByLabelText("Prepared file storage budget")).toHaveValue( + null, + ); + }); +}); diff --git a/web/src/components/settings/LimitField.tsx b/web/src/components/settings/LimitField.tsx new file mode 100644 index 000000000..bac2e2078 --- /dev/null +++ b/web/src/components/settings/LimitField.tsx @@ -0,0 +1,151 @@ +import { useId, useState } from "react"; + +import { Input } from "@/components/ui/input"; +import { + SETTINGS_NUMBER_WIDTH, + SettingFieldRow, +} from "@/pages/admin-settings/SettingField"; + +export interface LimitFieldProps { + label: string; + /** Stored value; equal to `unlimitedValue` when the limit is off. */ + value: string; + onChange: (value: string) => void; + /** Sentinel the backend reads as "no limit". */ + unlimitedValue?: string; + /** Fallback used when a limit is re-enabled and nothing was typed before. */ + fallbackValue?: string; + unlimitedLabel?: string; + /** Rendered in the row's trailing unit slot, e.g. "Mbps". */ + unit?: string; + /** + * Stored units per displayed unit, e.g. `1e9` to type a byte budget in GB. + * The stored value and the unlimited sentinel are unchanged — only what the + * admin reads and types is scaled. + */ + scale?: number; + hint?: string; + min?: number; + disabled?: boolean; + restartRequired?: boolean; +} + +/** + * Decimal places kept when a stored value is converted for display. Three is + * MB granularity on a GB budget, which is finer than any storage decision an + * admin makes here, and it keeps float division from surfacing as 53.68709…1. + */ +const SCALED_DECIMALS = 3; + +function toDisplayUnits(stored: string, scale: number): string { + if (scale === 1) return stored; + const trimmed = stored.trim(); + if (trimmed === "") return ""; + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) return stored; + return String(Number((parsed / scale).toFixed(SCALED_DECIMALS))); +} + +function toStoredUnits(displayed: string, scale: number): string { + if (scale === 1) return displayed; + const trimmed = displayed.trim(); + if (trimmed === "") return ""; + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) return displayed; + return String(Math.round(parsed * scale)); +} + +/** + * Number input paired with an "Unlimited" checkbox, replacing the + * "0 = unlimited" hint convention. The sentinel never reaches the admin's + * eyes, but the saved value is unchanged. + */ +export function LimitField({ + label, + value, + onChange, + unlimitedValue = "0", + fallbackValue = "", + unlimitedLabel = "Unlimited", + unit, + scale = 1, + hint, + min = 0, + disabled = false, + restartRequired = false, +}: LimitFieldProps) { + const controlId = useId(); + const checkboxId = useId(); + const hintId = useId(); + const unlimited = value.trim() === unlimitedValue; + // Remembers the limit that Unlimited replaced so unchecking restores it + // instead of dumping the admin back onto an empty box. + const [lastLimit, setLastLimit] = useState(fallbackValue); + // Keeps what was typed while it still round-trips to the stored value, so a + // scaled field does not eat the "." of "1." or rewrite "1.50" mid-keystroke. + const [draft, setDraft] = useState(null); + const displayed = + draft !== null && toStoredUnits(draft, scale) === value + ? draft + : toDisplayUnits(value, scale); + + function changeLimit(next: string) { + setDraft(next); + onChange(toStoredUnits(next, scale)); + } + + function toggleUnlimited(checked: boolean) { + setDraft(null); + if (checked) { + setLastLimit(unlimited ? fallbackValue : value); + onChange(unlimitedValue); + return; + } + onChange(lastLimit.trim() === unlimitedValue ? fallbackValue : lastLimit); + } + + return ( + +
+ {/* Ahead of the input, not after it: the row's unit slot sits to the + right of the control edge, so anything between the input and that + edge would knock this field's box out of line with the plain number + fields it is stacked against. */} + + changeLimit(e.target.value)} + disabled={disabled || unlimited} + className={SETTINGS_NUMBER_WIDTH} + aria-describedby={hint ? hintId : undefined} + /> +
+
+ ); +} diff --git a/web/src/components/settings/PathSettingField.tsx b/web/src/components/settings/PathSettingField.tsx new file mode 100644 index 000000000..cf3da08b0 --- /dev/null +++ b/web/src/components/settings/PathSettingField.tsx @@ -0,0 +1,93 @@ +import { useId, type ReactNode } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + SETTINGS_CONTROL_WIDTH, + SettingFieldRow, +} from "@/pages/admin-settings/SettingField"; +import { cn } from "@/lib/utils"; + +export interface PathSettingFieldProps { + label: string; + /** + * What the server runs while the field is blank, e.g. `/tmp/silo-transcode`. + * It is shown as the placeholder and is what "Reset to default" restores, so + * it has to be the real effective value — see + * `@/pages/admin-settings/settingsPathDefaults`. + */ + defaultValue: string; + /** Sentence under the label. Say what blank means, in words. */ + description?: ReactNode; + value: string; + onChange: (value: string) => void; + /** Marks the field with a restart badge; drive it from `useRestartKeys`. */ + restartRequired?: boolean; +} + +/** + * A settings row for a filesystem path whose stored value may be blank, where + * blank is a real choice ("follow the server's rule") rather than "unset". + * + * Two things follow from that, and neither works on a plain text field: the + * placeholder has to name the path blank resolves to, and an admin who typed an + * override needs a way back that does not require knowing what the built-in + * value was. Reset stages an empty string through the normal form flow, so the + * save bar confirms it like any other edit. + */ +export function PathSettingField({ + label, + defaultValue, + description, + value, + onChange, + restartRequired, +}: PathSettingFieldProps) { + const controlId = useId(); + const descriptionId = useId(); + // Nothing to reset while the field already runs the default, whether it is + // blank or holds the default path verbatim: clearing it would save a row + // nobody could see the effect of. + const overridden = value !== "" && value !== defaultValue; + + return ( + + {/* The action sits under the control rather than beside it: the row + reserves a unit slot to the right of every control, so anything + between the input and that slot would knock this field's box out of + line with the rows stacked against it. */} +
+ onChange(e.target.value)} + className={cn("border-muted-foreground/25", SETTINGS_CONTROL_WIDTH)} + aria-describedby={description ? descriptionId : undefined} + /> + {overridden ? ( + + ) : null} +
+
+ ); +} diff --git a/web/src/components/settings/ProviderTile.test.tsx b/web/src/components/settings/ProviderTile.test.tsx new file mode 100644 index 000000000..1767bb6ce --- /dev/null +++ b/web/src/components/settings/ProviderTile.test.tsx @@ -0,0 +1,145 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { + ProviderTile, + ProviderTileGrid, +} from "@/components/settings/ProviderTile"; + +describe("ProviderTileGrid", () => { + it("stops at two columns so tile headers do not truncate the provider name", () => { + const { container } = render( + + + , + ); + + const grid = container.firstElementChild; + expect(grid).toHaveClass("sm:grid-cols-2"); + expect(grid?.className).not.toMatch(/grid-cols-3/); + }); +}); + +describe("ProviderTile", () => { + it("names the tile after the provider so it can be found as a group", () => { + render( + , + ); + + expect( + screen.getByRole("group", { name: "OpenSubtitles" }), + ).toBeInTheDocument(); + expect(screen.getByText("Community subtitles")).toBeInTheDocument(); + }); + + it("labels each connection state and exposes it for styling", () => { + const { rerender } = render( + , + ); + expect(screen.getByText("Connected")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Not connected")).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText("Error")).toBeInTheDocument(); + expect(screen.getByText("401 — key rejected 2h ago")).toBeInTheDocument(); + expect(screen.getByRole("group", { name: "SubDL" })).toHaveAttribute( + "data-state", + "error", + ); + }); + + it("takes a pill text override", () => { + render( + , + ); + + expect(screen.getByText("Subscriber key")).toBeInTheDocument(); + expect(screen.queryByText("Connected")).not.toBeInTheDocument(); + }); + + it("runs the tile's own action", async () => { + const onClick = vi.fn(); + render( + , + ); + + await userEvent.click(screen.getByRole("button", { name: "Connect" })); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("hides the panel until the tile is expanded", () => { + const { rerender } = render( + + + , + ); + + expect(screen.queryByLabelText("API key")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Manage" })).toBeInTheDocument(); + + rerender( + + + , + ); + + expect(screen.getByLabelText("API key")).toBeInTheDocument(); + // The tile's own button gives way to the panel's action row. + expect( + screen.queryByRole("button", { name: "Manage" }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("group", { name: "TMDB" })).toHaveAttribute( + "data-expanded", + "true", + ); + }); + + it("disables everything inside while busy", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "Manage" })).toBeDisabled(); + }); +}); diff --git a/web/src/components/settings/ProviderTile.tsx b/web/src/components/settings/ProviderTile.tsx new file mode 100644 index 000000000..53f47c628 --- /dev/null +++ b/web/src/components/settings/ProviderTile.tsx @@ -0,0 +1,293 @@ +import { useId, type ReactNode } from "react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import "@/styles/admin-settings.css"; + +export type ProviderTileState = + "connected" | "not_connected" | "error" | "editing"; + +export interface ProviderTileAction { + label: string; + onClick: () => void; + disabled?: boolean; +} + +export interface ProviderTileProps { + name: string; + /** One short line under the name, e.g. "Community subtitles". */ + tagline?: ReactNode; + /** Two letters for the logo square; ignored when `logo` is given. */ + monogram?: string; + /** Background/foreground classes for the logo square. */ + monogramClass?: string; + /** Replaces the monogram, e.g. with an icon. */ + logo?: ReactNode; + state: ProviderTileState; + /** Overrides the default word for the state. */ + statePill?: string; + /** Small line at the foot. Only for what the state does not already say. */ + meta?: ReactNode; + /** The tile's own button — Connect, Manage, Fix. */ + primaryAction?: ProviderTileAction; + /** Chips beside the name, e.g. a `RestartBadge`. */ + badge?: ReactNode; + /** Controls level with the state, e.g. an enable switch. */ + headerActions?: ReactNode; + /** Spans the tile across the grid and reveals `children` as an inline panel. */ + expanded?: boolean; + /** Disables every control inside while a request is in flight. */ + busy?: boolean; + className?: string; + /** The inline connect panel: credential fields and their action row. */ + children?: ReactNode; +} + +const STATE_LABELS: Record = { + connected: "Connected", + not_connected: "Not connected", + error: "Error", + editing: "Editing", +}; + +const STATE_DOT_CLASSES: Record = { + connected: "bg-emerald-500", + not_connected: "bg-muted-foreground/40", + error: "bg-amber-500", + editing: "bg-[var(--settings-accent)]", +}; + +/** Dot plus word: the tile's one status signal, reused wherever it is listed. */ +export function ProviderState({ + state, + label, + className, +}: { + state: ProviderTileState; + label?: string; + className?: string; +}) { + return ( + + + ); +} + +/** + * Two-up grid for tiles; an expanded tile spans the full width inside it. + * It stops at two columns on purpose: the settings content column is clamped + * to `max-w-3xl`, so a third column leaves each tile too narrow for its header + * (monogram, name, badge, state pill) and truncates provider names. + */ +export function ProviderTileGrid({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
{children}
+ ); +} + +/** Two letters for a provider whose logo square is just its name, e.g. "AniList" → "AN". */ +export function providerMonogram(name: string): string { + return ( + name + .replace(/[^\p{L}\p{N}]/gu, "") + .slice(0, 2) + .toUpperCase() || "??" + ); +} + +/** Result of the last Test on a tile, kept in memory for the tile itself. */ +export interface ProviderTestState { + ok: boolean; + message: string; + /** `Date.now()` when the result came back. */ + at: number; + durationMs: number; +} + +function testedLabel(test: ProviderTestState): string { + const seconds = Math.max(0, Math.round((Date.now() - test.at) / 1000)); + const ago = + seconds < 60 ? `${seconds}s ago` : `${Math.round(seconds / 60)}m ago`; + return `Tested ${ago} · ${test.durationMs} ms`; +} + +/** + * The tile's state, from the three things every provider page knows: whether + * its panel is open, the last test, and whether the provider is actually usable + * right now. Shared so "connected" means the same thing on every page. + */ +export function resolveProviderTileState({ + expanded, + test, + connected, +}: { + expanded: boolean; + test: ProviderTestState | undefined; + connected: boolean; +}): ProviderTileState { + if (expanded) return "editing"; + if (test && !test.ok) return "error"; + return connected ? "connected" : "not_connected"; +} + +/** + * The action row every connect panel ends with. Every control in it carries a + * border or a fill at rest: a `ghost` button reads as plain text until it is + * hovered, which hid the secondary actions from admins who never hovered them. + */ +export function ProviderPanelActions({ + test, + children, +}: { + test?: ProviderTestState; + children: ReactNode; +}) { + return ( + // Buttons sit at the right edge — the same corner as the collapsed tile's + // Manage button — so the eye finds the actions in one place in both + // states. The test status takes the leftover left side. +
+ {test ? ( + + {test.ok ? testedLabel(test) : test.message} + + ) : null} + {children} +
+ ); +} + +/** + * One third-party provider: identity, connection state, and — once expanded — + * its credential panel inline instead of in a dialog, so the admin never loses + * sight of the list they are working through. + */ +export function ProviderTile({ + name, + tagline, + monogram, + monogramClass, + logo, + state, + statePill, + meta, + primaryAction, + badge, + headerActions, + expanded = false, + busy = false, + className, + children, +}: ProviderTileProps) { + const headingId = useId(); + const mark = logo ?? (monogram ?? name.slice(0, 2)).toUpperCase(); + + return ( +
+
+ +
+
+

+ {name} +

+ {badge} +
+ {tagline ? ( +

+ {tagline} +

+ ) : null} +
+
+ + {headerActions} +
+
+ + {/* Grid rows stretch the tiles to equal height, but a taller tagline + would otherwise push these rows down in one tile and not its + neighbor. `mt-auto` on the first bottom-block element pins the + action and meta lines to the tile foot, so they sit on the same + baseline across every tile in the row. */} + {!expanded && primaryAction ? ( +
+ +
+ ) : null} + + {meta ? ( +

+ {meta} +

+ ) : null} + + {expanded && children ? ( +
+ {children} +
+ ) : null} +
+ ); +} diff --git a/web/src/components/settings/RestartBadge.tsx b/web/src/components/settings/RestartBadge.tsx new file mode 100644 index 000000000..de50d0d97 --- /dev/null +++ b/web/src/components/settings/RestartBadge.tsx @@ -0,0 +1,28 @@ +import { RotateCw } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const RESTART_TITLE = "Takes effect after a server restart"; + +/** + * Small amber chip marking a setting whose value is only read at startup. + * Driven by the compiled restart-required key list (see `useRestartKeys`) so + * the fact never has to be hand-copied into a field hint. + */ +export function RestartBadge({ className }: { className?: string }) { + return ( + + + ); +} diff --git a/web/src/components/settings/SecretField.test.tsx b/web/src/components/settings/SecretField.test.tsx new file mode 100644 index 000000000..59bc26957 --- /dev/null +++ b/web/src/components/settings/SecretField.test.tsx @@ -0,0 +1,205 @@ +import { useState } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { SecretField } from "@/components/settings/SecretField"; + +function Harness({ + configured, + onChange, + onKeep, + onClear, + cleared, + disabled, +}: { + configured: boolean; + onChange?: (value: string) => void; + onKeep?: () => void; + onClear?: () => void; + cleared?: boolean; + disabled?: boolean; +}) { + const [value, setValue] = useState(""); + return ( + { + setValue(next); + onChange?.(next); + }} + onKeep={onKeep} + onClear={onClear} + cleared={cleared} + disabled={disabled} + /> + ); +} + +/** + * Models a settings-form parent: `null` is an untouched field, `""` is the + * dirty empty value that a staged clear writes on save. + */ +function FormHarness() { + const [staged, setStaged] = useState(null); + return ( + setStaged(null)} + onClear={() => setStaged("")} + cleared={staged === ""} + /> + ); +} + +const CLEAR = { name: "Clear saved value" }; +const KEEP = { name: "Keep saved value" }; + +describe("SecretField", () => { + it("shows a password input when nothing is saved", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + const input = screen.getByLabelText("Secret key"); + expect(input).toHaveAttribute("type", "password"); + expect(input).toHaveAttribute("placeholder", "Not configured"); + await user.type(input, "abc"); + expect(onChange).toHaveBeenLastCalledWith("abc"); + }); + + it("shows a saved secret as a masked, editable input", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + // No Replace step: the input is live and the mask stands in for the value. + const input = screen.getByLabelText("Secret key"); + expect(input).toHaveAttribute("type", "password"); + expect(input).toHaveAttribute("placeholder", "••••••••••••"); + expect( + screen.getByText( + "Type to replace the saved value; leave blank to keep it.", + ), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Replace/ }), + ).not.toBeInTheDocument(); + + await user.type(input, "new-secret"); + expect(onChange).toHaveBeenLastCalledWith("new-secret"); + }); + + it("delegates emptying the input to onKeep so the parent's draft stays authoritative", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const onKeep = vi.fn(); + render(); + + const input = screen.getByLabelText("Secret key"); + await user.type(input, "x"); + onChange.mockClear(); + + // Deleting back to empty means "keep the saved secret" — the parent + // reverts its draft instead of staging "" (which would clear on save). + await user.clear(input); + expect(onKeep).toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalledWith(""); + }); + + it("clears its own draft when no onKeep is supplied", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + const input = screen.getByLabelText("Secret key"); + await user.type(input, "x"); + onChange.mockClear(); + + await user.clear(input); + expect(onChange).toHaveBeenCalledWith(""); + }); + + it("does not treat an empty input as a keep while nothing is saved", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const onKeep = vi.fn(); + render(); + + const input = screen.getByLabelText("Secret key"); + await user.type(input, "x"); + await user.clear(input); + expect(onKeep).not.toHaveBeenCalled(); + expect(onChange).toHaveBeenCalledWith(""); + }); + + it("offers no clear action on a page that owns its own clear", () => { + render(); + + expect(screen.queryByRole("button", CLEAR)).not.toBeInTheDocument(); + }); + + it("offers no clear action while nothing is saved", () => { + render(); + + expect(screen.queryByRole("button", CLEAR)).not.toBeInTheDocument(); + }); + + it("offers no clear action while the field is read-only", () => { + render(); + + expect(screen.queryByRole("button", CLEAR)).not.toBeInTheDocument(); + }); + + it("stages a clear from an action that carries a border at rest", async () => { + const user = userEvent.setup(); + const onClear = vi.fn(); + render(); + + const action = screen.getByRole("button", CLEAR); + // Never `ghost`: an action that only appears on hover is invisible to the + // admins who need it. + expect(action).toHaveAttribute("data-variant", "outline"); + await user.click(action); + expect(onClear).toHaveBeenCalled(); + }); + + it("says a staged clear will be saved, and offers to keep the value instead", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", CLEAR)); + + const input = screen.getByLabelText("Secret key"); + expect(input).toHaveAttribute("placeholder", "Will be cleared on save"); + expect( + screen.getByText( + "Save clears the stored value; type to set a new one instead.", + ), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", KEEP)); + expect(input).toHaveAttribute("placeholder", "••••••••••••"); + expect(screen.getByRole("button", CLEAR)).toBeInTheDocument(); + }); + + it("stages a replacement when the admin types over a staged clear", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", CLEAR)); + const input = screen.getByLabelText("Secret key"); + await user.type(input, "replacement"); + + // A typed value is neither a keep nor a clear, so the action steps aside. + expect(input).toHaveValue("replacement"); + expect(input).toHaveAttribute("placeholder", "••••••••••••"); + expect(screen.queryByRole("button", CLEAR)).not.toBeInTheDocument(); + expect(screen.queryByRole("button", KEEP)).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/settings/SecretField.tsx b/web/src/components/settings/SecretField.tsx new file mode 100644 index 000000000..9f263fa52 --- /dev/null +++ b/web/src/components/settings/SecretField.tsx @@ -0,0 +1,144 @@ +import { useId } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { + SETTINGS_CONTROL_WIDTH, + SettingFieldRow, +} from "@/pages/admin-settings/SettingField"; + +export interface SecretFieldProps { + label: string; + /** Staged plaintext value; empty while the saved secret is kept. */ + value: string; + /** Whether the server already stores a value for this key. */ + configured: boolean; + onChange: (value: string) => void; + /** + * Called when the input is emptied while a saved value exists, so a + * settings-form parent can revert the staged draft (`form.resetValue`) + * instead of staging `""` — a dirty `""` would clear the secret on save. + * Draft-based parents that already skip empty values may omit it. + */ + onKeep?: () => void; + /** + * Stages clearing the saved secret, normally `form.setValue(key, "")` so the + * page's save bar writes the empty value with the rest of the batch. Pass it + * only where the surface has no clear action of its own; pages that own one + * (Disconnect, Clear credentials) keep a single clear per surface. + */ + onClear?: () => void; + /** + * Whether the parent currently has a clear staged for this key, e.g. + * `form.isDirty(key) && form.getValue(key) === ""`. Only meaningful + * alongside `onClear`. + */ + cleared?: boolean; + hint?: string; + disabled?: boolean; + restartRequired?: boolean; +} + +/** Description shown while a clear is staged, in place of `hint`. */ +const CLEARED_DESCRIPTION = + "Save clears the stored value; type to set a new one instead."; + +/** + * The single credential control for admin settings: one always-editable + * password input. A saved secret shows as a masked placeholder, typing stages + * a replacement, and emptying the input keeps the saved value, so no ordinary + * save can erase a secret by accident. + * + * Clearing one is always deliberate, and comes from exactly one of two places: + * a page-level action (Disconnect, Clear credentials), or — on surfaces that + * have none — this field's own opt-in `onClear` affordance, which stages the + * empty write for the page's save bar and can be taken back with "Keep saved + * value" or Discard. + */ +export function SecretField({ + label, + value, + configured, + onChange, + onKeep, + onClear, + cleared = false, + hint, + disabled = false, + restartRequired = false, +}: SecretFieldProps) { + const controlId = useId(); + const hintId = useId(); + + // A typed replacement outranks both other states: it is neither a keep nor a + // clear, so the action disappears until the input is empty again. + const clearStaged = cleared && value === ""; + const showAction = onClear != null && configured && !disabled && value === ""; + + const description = clearStaged + ? CLEARED_DESCRIPTION + : (hint ?? + (configured + ? "Type to replace the saved value; leave blank to keep it." + : undefined)); + + function keepSaved() { + if (onKeep) onKeep(); + else onChange(""); + } + + function handleChange(next: string) { + if (next === "" && configured) { + // Emptying the field means "keep the saved secret", never "clear it". + keepSaved(); + return; + } + onChange(next); + } + + return ( + + {/* The action sits ahead of the input, as in LimitField: the row's unit + slot is to the right of the control edge, so anything between the + input and that edge would knock this field out of line with the + plain rows it is stacked against. */} +
+ {showAction ? ( + + ) : null} + handleChange(e.target.value)} + disabled={disabled} + className={cn("border-muted-foreground/25", SETTINGS_CONTROL_WIDTH)} + aria-describedby={description ? hintId : undefined} + /> +
+
+ ); +} diff --git a/web/src/components/settings/SettingSlider.tsx b/web/src/components/settings/SettingSlider.tsx index 5b505ac77..628db6fe3 100644 --- a/web/src/components/settings/SettingSlider.tsx +++ b/web/src/components/settings/SettingSlider.tsx @@ -1,6 +1,8 @@ import { useState } from "react"; import { Slider } from "@/components/ui/slider"; +import { SETTINGS_CONTROL_WIDTH } from "@/pages/admin-settings/SettingField"; +import { cn } from "@/lib/utils"; interface SettingSliderProps { /** The persisted value. The thumb returns here if a save is rejected. */ @@ -42,8 +44,12 @@ export function SettingSlider({ const shown = draft ?? value; return ( + // Defaults to the shared settings control width so a slider row ends on the + // same edge as the inputs and selects it is stacked against.
-
-

- {title} -

- {description ? ( -

- {description} -

+
+
+

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : null} +
+ {actions ? ( +
{actions}
) : null}
-
{children}
+
{children}
); } diff --git a/web/src/components/settings/SettingsPageHeader.tsx b/web/src/components/settings/SettingsPageHeader.tsx new file mode 100644 index 000000000..50b298da8 --- /dev/null +++ b/web/src/components/settings/SettingsPageHeader.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; +import "@/styles/admin-settings.css"; + +export interface SettingsPageHeaderProps { + title: string; + /** Right-aligned page actions, level with the title. */ + actions?: ReactNode; + className?: string; +} + +/** + * The heading every admin settings page opens with: the page's name and + * nothing else. + */ +export function SettingsPageHeader({ + title, + actions, + className, +}: SettingsPageHeaderProps) { + return ( +
+

+ {title} +

+ {actions ? ( +
{actions}
+ ) : null} +
+ ); +} diff --git a/web/src/components/settings/SettingsPageRail.tsx b/web/src/components/settings/SettingsPageRail.tsx new file mode 100644 index 000000000..da34be436 --- /dev/null +++ b/web/src/components/settings/SettingsPageRail.tsx @@ -0,0 +1,48 @@ +import { SideNavItem, SideNavSection } from "@/components/SideNav"; +import { + ADMIN_SETTINGS_NAV, + type AdminSettingsSearchItem, +} from "@/lib/adminSettingsSearch"; +import { settingsPageHref } from "@/hooks/admin/useSettingsOverview"; + +export interface SettingsPageRailProps { + /** Page id of the page being shown, e.g. `general`. */ + activeId: string; + /** Pages to show — pass the search-filtered list; defaults to every page. */ + items?: readonly AdminSettingsSearchItem[]; +} + +/** + * The admin settings sibling nav: every settings page one hop away, rendered + * with the same `SideNavItem` rail the user settings page uses so the two + * settings surfaces read identically. Desktop only — on smaller screens the + * Overview (via the All settings link) is the directory. + */ +export function SettingsPageRail({ + activeId, + items = ADMIN_SETTINGS_NAV, +}: SettingsPageRailProps) { + return ( + + ); +} diff --git a/web/src/components/settings/SettingsSearchInput.tsx b/web/src/components/settings/SettingsSearchInput.tsx index 57d52b418..06e49188b 100644 --- a/web/src/components/settings/SettingsSearchInput.tsx +++ b/web/src/components/settings/SettingsSearchInput.tsx @@ -2,48 +2,50 @@ import { useEffect, useId, useRef } from "react"; import { Search, X } from "lucide-react"; import { Input } from "@/components/ui/input"; +import { searchShortcutLabel } from "@/lib/keyboardShortcut"; import { cn } from "@/lib/utils"; interface SettingsSearchInputProps { value: string; onChange: (value: string) => void; resultCount: number; - totalCount: number; placeholder?: string; - itemLabel?: string; emptyLabel?: string; className?: string; shortcutMediaQuery?: string; showShortcutHint?: boolean; + /** + * Focus the input on ⌘K / Ctrl-K. Turn off where another surface owns that + * shortcut (the admin area's command palette). + */ + captureShortcut?: boolean; } export function SettingsSearchInput({ value, onChange, resultCount, - totalCount, placeholder = "Search settings", - itemLabel = "settings sections", emptyLabel = "No matching settings", className, shortcutMediaQuery, showShortcutHint = false, + captureShortcut = true, }: SettingsSearchInputProps) { const inputId = useId(); const inputRef = useRef(null); const hasQuery = value.trim().length > 0; - const shortcutHint = - typeof navigator !== "undefined" && - /Mac|iPhone|iPad|iPod/.test(navigator.userAgent) - ? "⌘ K" - : "Ctrl K"; + const shortcutHint = searchShortcutLabel(); + // Idle shows nothing: a "12 settings pages" style count under an untouched + // box is noise. The line only speaks while a query is filtering. const status = hasQuery ? resultCount === 0 ? emptyLabel : `${resultCount} ${resultCount === 1 ? "match" : "matches"}` - : `${totalCount} ${itemLabel}`; + : null; useEffect(() => { + if (!captureShortcut) return; const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented || !(event.metaKey || event.ctrlKey)) return; if (event.key.toLowerCase() !== "k") return; @@ -63,7 +65,7 @@ export function SettingsSearchInput({ window.removeEventListener("keydown", onKeyDown, { capture: true }); document.removeEventListener("keydown", onKeyDown, { capture: true }); }; - }, [shortcutMediaQuery]); + }, [captureShortcut, shortcutMediaQuery]); return (
@@ -106,7 +108,12 @@ export function SettingsSearchInput({ ) : null}
-

+ {/* Always mounted so the live region reliably announces count changes; + visually collapses to nothing while idle. */} +

{status}

diff --git a/web/src/components/settings/SettingsSubheading.tsx b/web/src/components/settings/SettingsSubheading.tsx new file mode 100644 index 000000000..fae16359b --- /dev/null +++ b/web/src/components/settings/SettingsSubheading.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from "react"; + +export interface SettingsSubheadingProps { + /** The heading itself, e.g. "Per user". Sentence case; it is uppercased in CSS. */ + children: ReactNode; + /** + * One line under the heading, for a cluster whose scope is not obvious from + * the rows themselves ("Counted per login account…"). + */ + caption?: ReactNode; +} + +/** + * A divider inside a settings group: the one treatment for a sub-grouping label, + * so "Per user" in Downloads, "Mail Server" in Notifications and "Permission + * checks" in Infrastructure read as the same kind of thing. + * + * Deliberately quieter than a `SettingsGroup` title and than a row label — it + * separates rows without competing with them, which is what four different + * hand-rolled versions of this were each guessing at. + */ +export function SettingsSubheading({ + children, + caption, +}: SettingsSubheadingProps) { + return ( +
+

+ {children} +

+ {caption ? ( +

+ {caption} +

+ ) : null} +
+ ); +} diff --git a/web/src/components/sidebarItemNavigation.ts b/web/src/components/sidebarItemNavigation.ts index 054df71d3..4605c1e1b 100644 --- a/web/src/components/sidebarItemNavigation.ts +++ b/web/src/components/sidebarItemNavigation.ts @@ -1,9 +1,12 @@ export const SIDEBAR_COLLAPSE_DURATION_MS = 300; -export const SIDEBAR_TRANSITION_FALLBACK_MS = SIDEBAR_COLLAPSE_DURATION_MS + 80; +// How long a caller waits for the collapse when it cannot observe the +// transition itself. Kept well under the collapse duration: the gate exists to +// hide a detail skeleton, and holding a ready page longer than this reads as +// lag rather than motion. +export const SIDEBAR_TRANSITION_FALLBACK_MS = 150; // Hover expansion and hidden-tab rAF suspension must never hold the detail // shell indefinitely. Settling is preferred, but this is the absolute cap. -export const SIDEBAR_DETAILS_REVEAL_DEADLINE_MS = - SIDEBAR_TRANSITION_FALLBACK_MS * 2; +export const SIDEBAR_DETAILS_REVEAL_DEADLINE_MS = 760; export function sidebarDetailsRevealDelay(reduceMotion: boolean): number { return reduceMotion ? 0 : SIDEBAR_TRANSITION_FALLBACK_MS; diff --git a/web/src/hooks/admin/useSettingsOverview.test.ts b/web/src/hooks/admin/useSettingsOverview.test.ts new file mode 100644 index 000000000..9052ae271 --- /dev/null +++ b/web/src/hooks/admin/useSettingsOverview.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, it } from "vitest"; + +import { ADMIN_SETTINGS_NAV } from "@/lib/adminSettingsSearch"; +import { + buildSettingsOverview, + type OverviewCard, + type OverviewTile, + type SettingsOverviewInput, +} from "./useSettingsOverview"; + +function tile(input: SettingsOverviewInput, id: string): OverviewTile { + const found = buildSettingsOverview(input).tiles.find( + (entry) => entry.id === id, + ); + if (!found) throw new Error(`no tile ${id}`); + return found; +} + +function card(input: SettingsOverviewInput, id: string): OverviewCard { + const found = buildSettingsOverview(input).cards.find( + (entry) => entry.id === id, + ); + if (!found) throw new Error(`no card ${id}`); + return found; +} + +describe("buildSettingsOverview health tiles", () => { + it("degrades to placeholders rather than throwing when nothing has loaded", () => { + const model = buildSettingsOverview({}); + + expect(model.tiles).toHaveLength(5); + expect(model.cards).toHaveLength(12); + expect(tile({}, "storage").stateText).toBe("Not set up"); + expect(card({}, "general")).toEqual({ id: "general" }); + }); + + it("names the bucket when only public storage is configured", () => { + const storage = tile( + { storageAvailable: true, settings: { "s3.public_bucket": "silo-art" } }, + "storage", + ); + + expect(storage.state).toBe("ok"); + expect(storage.detail).toBe("S3 · silo-art"); + // A healthy tile is a fact, not a task: it carries no link. + expect(storage.action).toBeUndefined(); + }); + + it("summarises both buckets when private storage is configured too", () => { + const storage = tile( + { + storageAvailable: true, + settings: { + "s3.public_bucket": "silo-art", + "s3.private_bucket": "silo-private", + }, + }, + "storage", + ); + + expect(storage.detail).toBe("S3 · public + private"); + }); + + it("says a restart is pending once the first bucket is saved", () => { + // `storage_available` is settled when the S3 client is built at boot, so + // the save that configures the first bucket cannot flip it. "Not set up" + // there tells an admin who just did the work that it did not take. + const storage = tile( + { + storageAvailable: false, + settings: { "s3.public_bucket": "silo-art" }, + serverStatus: { + started_at: "2026-01-01T00:00:00Z", + restart_required: true, + restart_required_reason: "server_settings", + restart_requested: false, + }, + }, + "storage", + ); + + expect(storage.state).toBe("warn"); + expect(storage.stateText).toBe("Restart pending"); + expect(storage.detail).toBe("S3 · silo-art · applies after a restart"); + expect(storage.action).toEqual({ label: "Fix", page: "infrastructure" }); + }); + + it("still calls storage unconfigured when no bucket is saved", () => { + // A restart owed by some other save says nothing about storage. + const storage = tile( + { + storageAvailable: false, + serverStatus: { + started_at: "2026-01-01T00:00:00Z", + restart_required: true, + restart_required_reason: "server_settings", + restart_requested: false, + }, + }, + "storage", + ); + + expect(storage.state).toBe("off"); + expect(storage.stateText).toBe("Not set up"); + expect(storage.action).toEqual({ label: "Set up", page: "infrastructure" }); + }); + + it("does not read a restart another subsystem asked for as pending storage", () => { + const storage = tile( + { + storageAvailable: false, + settings: { "s3.public_bucket": "silo-art" }, + serverStatus: { + started_at: "2026-01-01T00:00:00Z", + restart_required: true, + restart_required_reason: "jellyfin_compat", + restart_requested: false, + }, + }, + "storage", + ); + + expect(storage.stateText).toBe("Not set up"); + }); + + it("reports the detected accelerator on the transcoding tile", () => { + const transcoding = tile( + { + settings: { + "playback.transcode_enabled": "true", + "playback.hw_accel": "auto", + }, + hwAccel: { + resolved: "vaapi", + render_devices: ["/dev/dri/renderD128"], + intel_detected: true, + source: "local", + }, + }, + "transcoding", + ); + + expect(transcoding.state).toBe("ok"); + expect(transcoding.stateText).toBe("Ready"); + expect(transcoding.detail).toBe("Auto · VA-API · /dev/dri/renderD128"); + }); + + it("scopes restart warnings to the tile whose keys are actually pending", () => { + // A saved database key owes a restart; neither storage nor transcoding + // changed, so neither tile may claim "Restart pending". + const base = { + settings: { + "playback.transcode_enabled": "true", + "s3.public_bucket": "silo-art", + }, + serverStatus: { + started_at: "2026-01-01T00:00:00Z", + restart_required: true, + restart_required_reason: "setting:database.max_connections", + restart_required_reasons: ["setting:database.max_connections"], + restart_requested: false, + }, + }; + expect(tile(base, "transcoding").stateText).toBe("Ready"); + expect(tile(base, "storage").stateText).toBe("Not set up"); + + // A pending playback key warns transcoding even when a LATER unrelated + // save overwrote the singular last-reason field. + const playbackPending = { + ...base, + serverStatus: { + ...base.serverStatus, + restart_required_reason: "jellyfin_compat", + restart_required_reasons: [ + "setting:playback.hw_accel", + "jellyfin_compat", + ], + }, + }; + expect(tile(playbackPending, "transcoding").stateText).toBe( + "Restart pending", + ); + expect(tile(playbackPending, "storage").stateText).toBe("Not set up"); + + const storagePending = { + ...base, + serverStatus: { + ...base.serverStatus, + restart_required_reasons: ["setting:s3.public_bucket"], + }, + }; + expect(tile(storagePending, "storage").stateText).toBe("Restart pending"); + expect(tile(storagePending, "transcoding").stateText).toBe("Ready"); + }); + + it("turns the transcoding tile amber while a settings restart is pending", () => { + const transcoding = tile( + { + settings: { "playback.transcode_enabled": "true" }, + serverStatus: { + started_at: "2026-01-01T00:00:00Z", + restart_required: true, + restart_required_reason: "server_settings", + restart_requested: false, + }, + }, + "transcoding", + ); + + expect(transcoding.state).toBe("warn"); + expect(transcoding.stateText).toBe("Restart pending"); + expect(transcoding.action).toEqual({ label: "Fix", page: "playback" }); + }); + + it("leaves transcoding alone for a restart another subsystem asked for", () => { + const transcoding = tile( + { + settings: { "playback.transcode_enabled": "true" }, + serverStatus: { + started_at: "2026-01-01T00:00:00Z", + restart_required: true, + restart_required_reason: "jellyfin_compat", + restart_requested: false, + }, + }, + "transcoding", + ); + + expect(transcoding.state).toBe("ok"); + }); + + it("marks search as informational on Postgres and healthy on Meilisearch", () => { + const postgres = tile( + { settings: { "catalog.search.provider": "postgres" } }, + "search", + ); + expect(postgres.state).toBe("info"); + expect(postgres.stateText).toBe("Postgres"); + expect(postgres.detail).toBe("Meilisearch not connected"); + + const meili = tile( + { + search: { + active_provider: "meilisearch", + meilisearch: { configured: true, healthy: true }, + } as SettingsOverviewInput["search"], + }, + "search", + ); + expect(meili.state).toBe("ok"); + expect(meili.stateText).toBe("Meilisearch"); + }); + + it("does not report Meilisearch as broken before its status resolves", () => { + const pending = tile( + { settings: { "catalog.search.provider": "meilisearch" } }, + "search", + ); + + expect(pending.state).toBe("info"); + expect(pending.detail).toBe("Checking connection"); + expect(pending.action).toBeUndefined(); + }); + + it("only calls email ready when it is on with a host and a sender address", () => { + expect( + tile({ settings: { "email.enabled": "true" } }, "email").stateText, + ).toBe("Not set up"); + // The server refuses to enable email without a from-address, but legacy + // rows and single-key writes can still store this state — it cannot send. + expect( + tile( + { + settings: { + "email.enabled": "true", + "email.smtp_host": "smtp.example.com", + }, + }, + "email", + ).stateText, + ).toBe("Not set up"); + expect( + tile( + { + settings: { + "email.enabled": "true", + "email.smtp_host": "smtp.example.com", + "email.from_address": "silo@example.com", + }, + }, + "email", + ).stateText, + ).toBe("Ready"); + }); +}); + +describe("buildSettingsOverview groups", () => { + it("emits one card per settings page id", () => { + expect(buildSettingsOverview({}).cards.map((entry) => entry.id)).toEqual([ + "general", + "infrastructure", + "appearance", + "security", + "library", + "playback", + "downloads", + "providers", + "watch-sync", + "ai", + "notifications", + "compatibility", + ]); + }); + + it("keeps the group manifest aligned with the overview ids", () => { + expect(buildSettingsOverview({}).cards.map((entry) => entry.id)).toEqual( + ADMIN_SETTINGS_NAV.map((item) => item.id), + ); + }); +}); diff --git a/web/src/hooks/admin/useSettingsOverview.ts b/web/src/hooks/admin/useSettingsOverview.ts new file mode 100644 index 000000000..4a619109e --- /dev/null +++ b/web/src/hooks/admin/useSettingsOverview.ts @@ -0,0 +1,430 @@ +import { useMemo } from "react"; + +import type { AdminServerStatus } from "@/api/types"; +import { emailReady } from "@/lib/emailReadiness"; +import { useBranding } from "@/hooks/useBranding"; +import { + useAdminSensitiveStatus, + useAdminServerSettings, + useAdminServerStatus, + useCatalogSearchStatus, + type CatalogSearchStatus, +} from "@/hooks/queries/admin/settings"; +import { + useHWAccelDetection, + type HWAccelInfo, +} from "@/hooks/queries/admin/system"; + +/** + * The settings pages the overview links to. The ids here are stable route + * segments and have to match the pages the settings layout mounts. + */ +export const ADMIN_SETTINGS_PAGE_IDS = [ + "general", + "infrastructure", + "appearance", + "security", + "library", + "playback", + "downloads", + "providers", + "watch-sync", + "ai", + "notifications", + "compatibility", +] as const; + +export type AdminSettingsPageID = (typeof ADMIN_SETTINGS_PAGE_IDS)[number]; + +/** Colour band a health tile reads in: green, amber, dimmed, or neutral blue. */ +export type OverviewState = "ok" | "warn" | "off" | "info"; + +export interface OverviewTileAction { + label: string; + page: AdminSettingsPageID; +} + +export interface OverviewTile { + id: string; + label: string; + state: OverviewState; + /** One or two words, e.g. "Healthy" or "Restart pending". */ + stateText: string; + /** Single supporting line under the state. */ + detail: string; + action?: OverviewTileAction; +} + +export interface OverviewCard { + id: AdminSettingsPageID; +} + +export interface SettingsOverviewModel { + /** True until the settings map has arrived; the page shows skeletons. */ + isLoading: boolean; + tiles: OverviewTile[]; + cards: OverviewCard[]; +} + +/** + * Everything the model is derived from. Kept as plain data (rather than read + * off hooks inside the builder) so the derivation is testable without a query + * client, and so a missing/failed query is just `undefined` here. + */ +export interface SettingsOverviewInput { + settings?: Record; + sensitiveConfigured?: readonly string[]; + storageAvailable?: boolean; + serverStatus?: AdminServerStatus; + search?: CatalogSearchStatus; + hwAccel?: HWAccelInfo; +} + +/** Deep link to one settings page. */ +export function settingsPageHref(page: string): string { + return `/admin/settings/${encodeURIComponent(page)}`; +} + +const TRUE_VALUES = new Set(["true", "1", "yes", "on"]); + +function readText( + settings: Record | undefined, + key: string, +): string { + return (settings?.[key] ?? "").trim(); +} + +function readBool( + settings: Record | undefined, + key: string, +): boolean { + return TRUE_VALUES.has(readText(settings, key).toLowerCase()); +} + +function readInt( + settings: Record | undefined, + key: string, +): number | null { + const parsed = Number.parseInt(readText(settings, key), 10); + return Number.isFinite(parsed) ? parsed : null; +} + +function join(parts: Array): string { + return parts + .filter((part): part is string => Boolean(part && part.trim())) + .join(" · "); +} + +function sentenceCase(value: string): string { + if (!value) return ""; + return value.charAt(0).toUpperCase() + value.slice(1); +} + +const HW_ACCEL_LABELS: Record = { + auto: "Auto", + qsv: "Quick Sync", + vaapi: "VA-API", + nvenc: "NVENC", + videotoolbox: "VideoToolbox", + none: "Software", +}; + +function hwAccelLabel(value: string): string { + return HW_ACCEL_LABELS[value] ?? value.toUpperCase(); +} + +/** + * How transcoding is set up, in one phrase: the configured mode, and for + * "auto" the mode detection actually resolved to. + */ +function transcodeModeLabel( + configured: string, + detection: HWAccelInfo | undefined, +): string { + const mode = configured || "auto"; + if (mode !== "auto") return hwAccelLabel(mode); + const resolved = detection?.resolved; + if (!resolved) return "Auto"; + if (resolved === "none") return "Auto · software"; + return `Auto · ${hwAccelLabel(resolved)}`; +} + +const SEARCH_PROVIDER_LABELS: Record = { + postgres: "Postgres", + meilisearch: "Meilisearch", +}; + +function searchProviderLabel(value: string): string { + return SEARCH_PROVIDER_LABELS[value] ?? sentenceCase(value); +} + +/** + * Restart reasons that name a subsystem other than the settings batch. Only + * used against servers that predate the accumulated per-key reasons list, + * where the single last-writer reason is the closest signal there is. + */ +const NON_SETTINGS_RESTART_REASONS = new Set([ + "jellyfin_compat", + "ratelimit_backend", + "plugin_auth_binding", + "plugin_task_binding", +]); + +function legacySettingsRestartPending(status: AdminServerStatus): boolean { + const reason = (status.restart_required_reason ?? "").trim(); + return reason === "" || !NON_SETTINGS_RESTART_REASONS.has(reason); +} + +/** + * Whether a saved setting under one of `prefixes` is waiting for a restart. + * + * The server accumulates one "setting:" reason per restart-required save, + * so a tile can warn about its own keys and stay quiet for everyone else's — + * and a later unrelated save cannot overwrite the evidence. An older server + * without the list falls back to the coarse last-reason heuristic, which warns + * for any settings save rather than missing a real one. + */ +function settingsRestartPending( + status: AdminServerStatus | undefined, + prefixes: readonly string[], +): boolean { + if (!status?.restart_required) return false; + const reasons = status.restart_required_reasons; + if (!reasons) return legacySettingsRestartPending(status); + return reasons.some((reason) => { + if (!reason.startsWith("setting:")) return false; + const key = reason.slice("setting:".length); + return prefixes.some((prefix) => key.startsWith(prefix)); + }); +} + +// --------------------------------------------------------------------------- +// Health tiles +// --------------------------------------------------------------------------- + +/** + * The link a tile carries. Only a tile the admin has to do something about + * gets one — a healthy tile is a fact, not a task. + */ +function tileAction( + state: OverviewState, + page: AdminSettingsPageID, +): OverviewTileAction | undefined { + if (state === "warn") return { label: "Fix", page }; + if (state === "off") return { label: "Set up", page }; + return undefined; +} + +function buildTiles(input: SettingsOverviewInput): OverviewTile[] { + const { settings } = input; + const configured = new Set(input.sensitiveConfigured ?? []); + + const publicBucket = readText(settings, "s3.public_bucket"); + const privateBucket = readText(settings, "s3.private_bucket"); + const storageReady = input.storageAvailable === true; + const bucketDetail = privateBucket + ? "S3 · public + private" + : publicBucket + ? `S3 · ${publicBucket}` + : "No bucket configured"; + // `storage_available` is decided when the S3 client is built at boot, so the + // save that first configures a bucket cannot flip it — only the restart that + // save already asked for can. Reporting "Not set up" in that window tells an + // admin who just did the work that it did not take. + const storageRestartPending = + !storageReady && + publicBucket !== "" && + settingsRestartPending(input.serverStatus, ["s3."]); + const storageState: OverviewState = storageReady + ? "ok" + : storageRestartPending + ? "warn" + : "off"; + + const maxConnections = readInt(settings, "database.max_connections"); + const redisConfigured = + configured.has("redis.url") || readText(settings, "redis.url") !== ""; + + const transcodeEnabled = readBool(settings, "playback.transcode_enabled"); + const transcodeMode = transcodeModeLabel( + readText(settings, "playback.hw_accel"), + input.hwAccel, + ); + const renderDevice = input.hwAccel?.render_devices?.[0] ?? ""; + const playbackRestartPending = settingsRestartPending(input.serverStatus, [ + "playback.", + ]); + const transcodeState: OverviewState = playbackRestartPending + ? "warn" + : transcodeEnabled + ? "ok" + : "off"; + + const activeSearch = + input.search?.active_provider || + readText(settings, "catalog.search.provider") || + "postgres"; + const searchStatusResolved = input.search != null; + const meiliConfigured = input.search?.meilisearch.configured ?? false; + const meiliHealthy = input.search?.meilisearch.healthy ?? false; + const searchState: OverviewState = + activeSearch === "meilisearch" + ? !searchStatusResolved + ? "info" + : meiliHealthy + ? "ok" + : "warn" + : meiliConfigured + ? "warn" + : "info"; + + const emailHost = readText(settings, "email.smtp_host"); + const mailReady = emailReady( + readBool(settings, "email.enabled"), + emailHost, + readText(settings, "email.from_address"), + ); + const emailState: OverviewState = mailReady ? "ok" : "off"; + + return [ + { + id: "storage", + label: "Storage", + state: storageState, + stateText: storageReady + ? "Healthy" + : storageRestartPending + ? "Restart pending" + : "Not set up", + detail: storageReady + ? bucketDetail + : storageRestartPending + ? `${bucketDetail} · applies after a restart` + : "Artwork and uploads have nowhere to go", + action: tileAction(storageState, "infrastructure"), + }, + { + id: "database", + label: "Database", + state: "ok", + stateText: "Healthy", + detail: join([ + "Postgres", + maxConnections ? `max ${maxConnections} connections` : null, + redisConfigured ? "Redis" : null, + ]), + }, + { + id: "transcoding", + label: "Transcoding", + state: transcodeState, + stateText: playbackRestartPending + ? "Restart pending" + : transcodeEnabled + ? "Ready" + : "Off", + detail: playbackRestartPending + ? "Saved changes apply after a restart" + : transcodeEnabled + ? join([transcodeMode, renderDevice]) + : "Clients only get what they can already play", + action: tileAction(transcodeState, "playback"), + }, + { + id: "search", + label: "Search", + state: searchState, + stateText: searchProviderLabel(activeSearch), + detail: + activeSearch === "meilisearch" + ? !searchStatusResolved + ? "Checking connection" + : meiliHealthy + ? "Meilisearch connected" + : "Meilisearch not reachable" + : meiliConfigured + ? "Meilisearch configured but not active" + : "Meilisearch not connected", + action: tileAction(searchState, "library"), + }, + { + id: "email", + label: "Email", + state: emailState, + stateText: mailReady ? "Ready" : "Not set up", + detail: mailReady + ? `SMTP · ${emailHost}` + : "Invites and resets can't send", + action: tileAction(emailState, "notifications"), + }, + ]; +} + +// --------------------------------------------------------------------------- +// Settings groups +// --------------------------------------------------------------------------- + +function buildCards(): OverviewCard[] { + return ADMIN_SETTINGS_PAGE_IDS.map((id) => ({ id })); +} + +/** Derives the whole overview model from already-fetched data. */ +export function buildSettingsOverview( + input: SettingsOverviewInput, +): SettingsOverviewModel { + const tiles = buildTiles(input); + const cards = buildCards(); + + return { isLoading: false, tiles, cards }; +} + +/** + * Live settings state for the admin settings landing page. Every query it + * reads is one an individual page already loads, so opening the overview warms + * the caches those pages go on to use. + */ +export function useSettingsOverview(): SettingsOverviewModel { + const { data: settings, isLoading: settingsLoading } = + useAdminServerSettings(); + const { data: sensitive } = useAdminSensitiveStatus(); + const { data: serverStatus } = useAdminServerStatus(); + const branding = useBranding(); + + // Postgres search has no external service to check. Avoid paying for the + // Meilisearch status request unless this server actually selected it. + const searchProvider = + (settings?.["catalog.search.provider"] ?? "").trim() || "postgres"; + const { data: search } = useCatalogSearchStatus( + settings != null && searchProvider === "meilisearch", + ); + + // Hardware detection shells out to ffmpeg on the transcode host, so it is + // only asked for when transcoding could actually use it. + const hwAccelMode = (settings?.["playback.hw_accel"] ?? "").trim(); + const transcodeEnabled = TRUE_VALUES.has( + (settings?.["playback.transcode_enabled"] ?? "").trim().toLowerCase(), + ); + const { data: hwAccel } = useHWAccelDetection( + settings != null && transcodeEnabled && hwAccelMode !== "none", + ); + + return useMemo(() => { + const model = buildSettingsOverview({ + settings, + sensitiveConfigured: sensitive?.configured, + storageAvailable: branding.storageAvailable, + serverStatus, + search, + hwAccel, + }); + return { ...model, isLoading: settingsLoading && settings == null }; + }, [ + branding.storageAvailable, + hwAccel, + search, + sensitive?.configured, + serverStatus, + settings, + settingsLoading, + ]); +} diff --git a/web/src/hooks/queries/admin/settings.ts b/web/src/hooks/queries/admin/settings.ts index 61337a8bd..a4d474a95 100644 --- a/web/src/hooks/queries/admin/settings.ts +++ b/web/src/hooks/queries/admin/settings.ts @@ -20,8 +20,20 @@ interface SensitiveStatusResponse { managed_by_env?: string[]; } +/** + * server_settings keys surfaced by GET /settings/overlay-config, in the order + * the admin overlay page presents them. The page edits exactly these, and + * saving any of them must refresh every profile's cached overlay config. + */ +export const OVERLAY_CONFIG_SERVER_KEYS = [ + "defaults.card_quick_actions_enabled", + "defaults.card_quick_actions", + "overlays.enabled", + "defaults.card_overlays", +] as const; + function affectsOverlayConfig(key: string) { - return key === "overlays.enabled" || key === "defaults.card_overlays"; + return (OVERLAY_CONFIG_SERVER_KEYS as readonly string[]).includes(key); } export interface CatalogSearchStatus { @@ -89,6 +101,27 @@ export function useAdminServerSettings() { }); } +/** Shape of `GET /admin/settings/restart-keys`. */ +export interface RestartKeysResponse { + keys: string[]; + prefixes: string[]; +} + +/** + * The compiled restart-required registry (`internal/config/restart_keys.go`). + * It only changes across deploys, so it is cached aggressively and never + * retried: an older server without the endpoint degrades to "nothing needs a + * restart" rather than to a broken settings page. + */ +export function useAdminRestartKeys() { + return useQuery({ + queryKey: adminKeys.restartKeys(), + queryFn: () => api("/admin/settings/restart-keys"), + staleTime: 5 * 60_000, + retry: false, + }); +} + export function useAdminServerStatus() { return useQuery({ queryKey: adminKeys.serverStatus(), @@ -249,10 +282,11 @@ export function useCheckAdminSettingsConnection() { }); } -export function useCatalogSearchStatus() { +export function useCatalogSearchStatus(enabled = true) { return useQuery({ queryKey: adminKeys.catalogSearchStatus(), queryFn: () => api("/admin/catalog/search/status"), + enabled, staleTime: 15_000, }); } diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index b07b4c88b..01b76fff5 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -455,6 +455,7 @@ export const adminKeys = { sessions: () => ["admin", "sessions"] as const, serverSettings: () => ["admin", "serverSettings"] as const, serverStatus: () => ["admin", "serverStatus"] as const, + restartKeys: () => ["admin", "restartKeys"] as const, catalogSearchStatus: () => ["admin", "catalogSearchStatus"] as const, jellyfinCompatStatus: () => ["admin", "jellyfinCompatStatus"] as const, requestsRoot: () => ["admin", "requests"] as const, diff --git a/web/src/hooks/queries/mediaSurfaceRefresh.test.ts b/web/src/hooks/queries/mediaSurfaceRefresh.test.ts index 6b172e014..90e957969 100644 --- a/web/src/hooks/queries/mediaSurfaceRefresh.test.ts +++ b/web/src/hooks/queries/mediaSurfaceRefresh.test.ts @@ -371,6 +371,28 @@ describe("invalidateMediaSurfaceQueries", () => { ).toBe(true); }); + it("leaves home section queries alone for a library-scoped change", async () => { + const queryClient = new QueryClient(); + const homeLayoutKey = sectionKeys.homeLayout(); + const homeItemsKey = sectionKeys.homeItems("recently-added"); + + queryClient.setQueryData(homeLayoutKey, { sections: [] }); + queryClient.setQueryData(homeItemsKey, { + section: { id: "recently-added", items: [] }, + }); + + await invalidateMediaSurfaceQueries(queryClient, { libraryId: 3 }); + + expect(queryClient.getQueryState(homeLayoutKey)?.isInvalidated).toBe(false); + expect(queryClient.getQueryState(homeItemsKey)?.isInvalidated).toBe(false); + + // An unscoped change — a favorite, a watch, a catalog import — still does. + await invalidateMediaSurfaceQueries(queryClient); + + expect(queryClient.getQueryState(homeLayoutKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(homeItemsKey)?.isInvalidated).toBe(true); + }); + it("does not invalidate collection queries for a different library scope", async () => { const queryClient = new QueryClient(); const moviesKey = libraryCollectionKeys.list(1); diff --git a/web/src/hooks/queries/sections.ts b/web/src/hooks/queries/sections.ts index 92d7ac7a4..7a63fb739 100644 --- a/web/src/hooks/queries/sections.ts +++ b/web/src/hooks/queries/sections.ts @@ -14,6 +14,18 @@ import type { import { sectionKeys } from "./keys"; import { invalidateAdminCollectionQueries } from "./collectionSurfaceRefresh"; +/** + * Home section data outlives the default client-wide gcTime on purpose. + * + * The layout query loses its observer the moment home unmounts, and the section + * items are fetched observer-less through `queryClient.fetchQuery`, so the + * 10 minute default evicts everything home needs mid-session and the next visit + * repaints the whole skeleton grid. Keeping the entries for an hour makes a + * return render from cache; invalidation still drives real refreshes. + */ +export const HOME_SECTION_STALE_TIME = 10 * 60 * 1000; +export const HOME_SECTION_GC_TIME = 60 * 60 * 1000; + interface RawSectionOverride { ID?: string; SectionID?: string; @@ -80,7 +92,8 @@ export function useHomeLayout() { return useQuery({ queryKey: sectionKeys.homeLayout(), queryFn: () => api("/home/layout"), - staleTime: 5 * 60 * 1000, + staleTime: HOME_SECTION_STALE_TIME, + gcTime: HOME_SECTION_GC_TIME, }); } diff --git a/web/src/hooks/useLongPress.ts b/web/src/hooks/useLongPress.ts index acae6c7e4..37b8b92d6 100644 --- a/web/src/hooks/useLongPress.ts +++ b/web/src/hooks/useLongPress.ts @@ -37,12 +37,29 @@ export function useLongPress( useEffect(() => { const target = targetRef?.current; if (!target || !enabled) return; + // Aliased so the null check above narrows inside the hoisted handlers below. + const targetElement = target; let timer: number | null = null; + let suppressionCleanupTimer: number | null = null; let press: { pointerId: number; clientX: number; clientY: number } | null = null; let suppressUntil = 0; + function removeSuppressionListeners() { + if (suppressionCleanupTimer !== null) { + window.clearTimeout(suppressionCleanupTimer); + suppressionCleanupTimer = null; + } + targetElement.removeEventListener("click", handleClickCapture, true); + targetElement.removeEventListener("contextmenu", handleContextMenu); + } + + function installSuppressionListeners() { + targetElement.addEventListener("click", handleClickCapture, true); + targetElement.addEventListener("contextmenu", handleContextMenu); + } + function stopTracking() { if (timer !== null) { window.clearTimeout(timer); @@ -62,17 +79,21 @@ export function useLongPress( Math.abs(event.clientY - press.clientY) > LONG_PRESS_MOVE_TOLERANCE_PX ) { stopTracking(); + removeSuppressionListeners(); } } function handlePointerUp(event: PointerEvent) { if (press && event.pointerId !== press.pointerId) return; + const longPressFired = Date.now() <= suppressUntil; stopTracking(); + if (!longPressFired) removeSuppressionListeners(); } function handlePointerDown(event: PointerEvent) { suppressUntil = 0; stopTracking(); + removeSuppressionListeners(); if (event.pointerType !== "touch" && event.pointerType !== "pen") return; press = { @@ -80,6 +101,11 @@ export function useLongPress( clientX: event.clientX, clientY: event.clientY, }; + // These listeners are only needed while a touch/pen gesture is active + // and for the short suppression window after a successful hold. Keeping + // them off every idle card avoids hundreds of redundant listeners on + // large home screens, which is especially noticeable in mobile Firefox. + installSuppressionListeners(); // Movement can leave the card (a swipe), so the follow-up listeners live // on the window rather than the element. window.addEventListener("pointermove", handlePointerMove); @@ -91,6 +117,10 @@ export function useLongPress( press = null; suppressUntil = Date.now() + LONG_PRESS_SUPPRESS_MS; onLongPressRef.current(); + suppressionCleanupTimer = window.setTimeout(() => { + suppressUntil = 0; + removeSuppressionListeners(); + }, LONG_PRESS_SUPPRESS_MS); }, LONG_PRESS_DELAY_MS); } @@ -99,6 +129,7 @@ export function useLongPress( suppressUntil = 0; event.preventDefault(); event.stopPropagation(); + removeSuppressionListeners(); } function handleContextMenu(event: Event) { @@ -106,14 +137,11 @@ export function useLongPress( event.preventDefault(); } - target.addEventListener("pointerdown", handlePointerDown); - target.addEventListener("click", handleClickCapture, true); - target.addEventListener("contextmenu", handleContextMenu); + targetElement.addEventListener("pointerdown", handlePointerDown); return () => { stopTracking(); - target.removeEventListener("pointerdown", handlePointerDown); - target.removeEventListener("click", handleClickCapture, true); - target.removeEventListener("contextmenu", handleContextMenu); + removeSuppressionListeners(); + targetElement.removeEventListener("pointerdown", handlePointerDown); }; }, [enabled, targetRef]); } diff --git a/web/src/hooks/useMediaQuery.ts b/web/src/hooks/useMediaQuery.ts new file mode 100644 index 000000000..14292d2f7 --- /dev/null +++ b/web/src/hooks/useMediaQuery.ts @@ -0,0 +1,48 @@ +import { useCallback, useSyncExternalStore } from "react"; + +// One MediaQueryList per distinct query, shared by every consumer: getSnapshot +// runs on every render of every subscriber (hundreds of cards on a home page), +// and matchMedia re-parses the query on each call. Keyed to the matchMedia +// function itself so a test that stubs matchMedia never reads a list cached +// from a different stub. +let cachedMatchMedia: typeof window.matchMedia | undefined; +const mediaQueryLists = new Map(); + +function getMediaQueryList(query: string): MediaQueryList | undefined { + if (typeof window === "undefined" || !window.matchMedia) return undefined; + if (window.matchMedia !== cachedMatchMedia) { + cachedMatchMedia = window.matchMedia; + mediaQueryLists.clear(); + } + let list = mediaQueryLists.get(query); + if (!list) { + list = window.matchMedia(query); + mediaQueryLists.set(query, list); + } + return list; +} + +/** + * Tracks a CSS media query. + * + * `fallback` answers where matchMedia does not exist — server rendering, tests, + * and older browsers — so a caller can say which side of the query those + * environments should be treated as. + */ +export function useMediaQuery(query: string, fallback = false): boolean { + const subscribe = useCallback( + (onStoreChange: () => void) => { + const media = getMediaQueryList(query); + media?.addEventListener("change", onStoreChange); + return () => media?.removeEventListener("change", onStoreChange); + }, + [query], + ); + + const getSnapshot = useCallback( + () => getMediaQueryList(query)?.matches ?? fallback, + [query, fallback], + ); + + return useSyncExternalStore(subscribe, getSnapshot, () => fallback); +} diff --git a/web/src/hooks/useOverlayPrefs.test.tsx b/web/src/hooks/useOverlayPrefs.test.tsx index 03e664cd6..705ab883a 100644 --- a/web/src/hooks/useOverlayPrefs.test.tsx +++ b/web/src/hooks/useOverlayPrefs.test.tsx @@ -6,24 +6,40 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ api: vi.fn(), setValue: vi.fn(), + clearValue: vi.fn(), + effective: undefined as Record | undefined, + profileId: null as string | null, })); -vi.mock("@/api/client", () => ({ - api: mocks.api, -})); +vi.mock("@/api/client", async () => { + const actual = + await vi.importActual("@/api/client"); + return { ...actual, api: mocks.api }; +}); -vi.mock("@/hooks/queries/settingValues", () => ({ - useEffectiveSettings: () => ({ data: undefined, isLoading: false }), +// Keep the real query-key builder and error taxonomy so the hook's optimistic +// cache writes are exercised against the actual key shape; only the two hooks +// that reach the network are replaced. +vi.mock("@/hooks/queries/settingValues", async (importOriginal) => ({ + ...(await importOriginal()), + useEffectiveSettings: () => ({ data: mocks.effective, isLoading: false }), useSetSettingValue: () => ({ mutate: mocks.setValue }), + useClearSettingValue: () => ({ + mutateAsync: mocks.clearValue, + isPending: false, + }), })); vi.mock("@/utils/storage", () => ({ storage: { KEYS: { PROFILE_ID: "profile_id" }, - get: () => null, + get: () => mocks.profileId, }, })); +import { ApiClientError } from "@/api/client"; +import { SETTING_KEYS } from "@/lib/settingsContract"; + import { useOverlayPrefs } from "./useOverlayPrefs"; import { useUpdateServerSettings } from "./queries/admin/settings"; @@ -40,10 +56,26 @@ function createWrapper() { }; } +function effectiveOverlayValue(value: unknown) { + return { + data: { + [SETTING_KEYS.UI_CARD_OVERLAYS]: { + key: SETTING_KEYS.UI_CARD_OVERLAYS, + value, + source: value == null ? "default" : "profile", + }, + } as unknown, + isLoading: false, + }; +} + describe("useOverlayPrefs", () => { beforeEach(() => { mocks.api.mockReset(); mocks.setValue.mockReset(); + mocks.clearValue.mockReset(); + mocks.effective = undefined; + mocks.profileId = null; }); afterEach(cleanup); @@ -57,10 +89,219 @@ describe("useOverlayPrefs", () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(mocks.api).toHaveBeenCalledWith("/settings/overlay-config"); - expect(result.current.enabled).toBe(true); + expect(result.current.overlaysEnabled).toBe(true); + expect(result.current.prefs).not.toBeNull(); + expect(result.current).not.toHaveProperty("enabled"); + expect(result.current.quickActionsEnabled).toBe(false); + expect(result.current).not.toHaveProperty("quickActionsGloballyEnabled"); + expect(result.current.quickActionPreference).toBe("both"); + expect(result.current.quickActionMode).toBe("none"); expect(result.current).not.toHaveProperty("watchedIndicatorStyle"); }); + it("uses disabled and mode values from the admin defaults for an unset profile", async () => { + mocks.api.mockResolvedValue({ + enabled: true, + quick_actions_enabled: false, + quick_actions_default: "favorites", + }); + const { result } = renderHook(() => useOverlayPrefs(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.quickActionsEnabled).toBe(false); + expect(result.current.quickActionPreference).toBe("favorites"); + expect(result.current.quickActionMode).toBe("none"); + }); + + it("inherits an enabled admin default for a profile that has not chosen", async () => { + mocks.profileId = "profile-1"; + mocks.effective = {}; + mocks.api.mockResolvedValue({ + enabled: true, + quick_actions_enabled: true, + quick_actions_default: "favorites", + }); + const { result } = renderHook(() => useOverlayPrefs(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.quickActionsEnabled).toBe(true); + expect(result.current.quickActionMode).toBe("favorites"); + }); + + it("lets a profile opt in to quick actions while the admin default is off", async () => { + mocks.profileId = "profile-1"; + mocks.effective = { + "ui.card_quick_actions": { value: "watched" }, + "ui.card_quick_actions_enabled": { value: true }, + }; + mocks.api.mockResolvedValue({ + enabled: true, + quick_actions_enabled: false, + quick_actions_default: "favorites", + }); + const { result } = renderHook(() => useOverlayPrefs(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.quickActionsEnabled).toBe(true); + expect(result.current.quickActionPreference).toBe("watched"); + expect(result.current.quickActionMode).toBe("watched"); + + act(() => result.current.setQuickActionsEnabled(false)); + act(() => result.current.setQuickActionMode("both")); + expect(mocks.setValue).toHaveBeenCalledWith( + { + key: "ui.card_quick_actions_enabled", + value: false, + identity: { scope: "profile" }, + }, + expect.objectContaining({ onError: expect.any(Function) }), + ); + expect(mocks.setValue).toHaveBeenCalledWith( + { + key: "ui.card_quick_actions", + value: "both", + identity: { scope: "profile" }, + }, + expect.objectContaining({ onError: expect.any(Function) }), + ); + }); + + it("lets a profile opt out of quick actions while the admin default is on", async () => { + mocks.profileId = "profile-1"; + mocks.effective = { + "ui.card_quick_actions": { value: "watched" }, + "ui.card_quick_actions_enabled": { value: false }, + }; + mocks.api.mockResolvedValue({ + enabled: true, + quick_actions_enabled: true, + quick_actions_default: "favorites", + }); + const { result } = renderHook(() => useOverlayPrefs(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.quickActionsEnabled).toBe(false); + expect(result.current.quickActionPreference).toBe("watched"); + expect(result.current.quickActionMode).toBe("none"); + + act(() => result.current.setQuickActionsEnabled(true)); + act(() => result.current.setQuickActionMode("both")); + expect(mocks.setValue).toHaveBeenCalledWith( + { + key: "ui.card_quick_actions_enabled", + value: true, + identity: { scope: "profile" }, + }, + expect.objectContaining({ onError: expect.any(Function) }), + ); + expect(mocks.setValue).toHaveBeenCalledWith( + { + key: "ui.card_quick_actions", + value: "both", + identity: { scope: "profile" }, + }, + expect.objectContaining({ onError: expect.any(Function) }), + ); + }); + + it("inherits a disabled overlay default for a profile that has not chosen", async () => { + mocks.profileId = "profile-1"; + mocks.effective = {}; + mocks.api.mockResolvedValue({ enabled: false }); + const { result } = renderHook(() => useOverlayPrefs(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.overlaysEnabled).toBe(false); + expect(result.current.prefs).toBeNull(); + }); + + it("lets a profile opt in to overlays while the server default is off", async () => { + mocks.profileId = "profile-1"; + mocks.effective = { "ui.card_overlays_enabled": { value: true } }; + mocks.api.mockResolvedValue({ enabled: false }); + const { result } = renderHook(() => useOverlayPrefs(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.overlaysEnabled).toBe(true); + expect(result.current.prefs).not.toBeNull(); + + act(() => result.current.setOverlaysEnabled(false)); + expect(mocks.setValue).toHaveBeenCalledWith( + { + key: "ui.card_overlays_enabled", + value: false, + identity: { scope: "profile" }, + }, + expect.objectContaining({ onError: expect.any(Function) }), + ); + }); + + it("lets a profile opt out of overlays while the server default is on", async () => { + mocks.profileId = "profile-1"; + mocks.effective = { "ui.card_overlays_enabled": { value: false } }; + mocks.api.mockResolvedValue({ enabled: true }); + const { result } = renderHook(() => useOverlayPrefs(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.overlaysEnabled).toBe(false); + expect(result.current.prefs).toBeNull(); + + act(() => result.current.setOverlaysEnabled(false)); + expect(mocks.setValue).not.toHaveBeenCalled(); + + act(() => result.current.setOverlaysEnabled(true)); + expect(mocks.setValue).toHaveBeenCalledWith( + { + key: "ui.card_overlays_enabled", + value: true, + identity: { scope: "profile" }, + }, + expect.objectContaining({ onError: expect.any(Function) }), + ); + }); + + it("writes an overlay opt-in for a profile that has expressed no preference", async () => { + mocks.profileId = "profile-1"; + mocks.effective = {}; + mocks.api.mockResolvedValue({ enabled: false }); + const { result } = renderHook(() => useOverlayPrefs(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => result.current.setOverlaysEnabled(true)); + expect(mocks.setValue).toHaveBeenCalledWith( + { + key: "ui.card_overlays_enabled", + value: true, + identity: { scope: "profile" }, + }, + expect.objectContaining({ onError: expect.any(Function) }), + ); + }); + it("refreshes the shared overlay configuration immediately after an admin save", async () => { const queryClient = new QueryClient({ defaultOptions: { @@ -75,11 +316,132 @@ describe("useOverlayPrefs", () => { const { result } = renderHook(() => useUpdateServerSettings(), { wrapper }); await act(async () => { - await result.current.mutateAsync({ "defaults.card_overlays": "{}" }); + await result.current.mutateAsync({ + "defaults.card_quick_actions": "favorites", + }); }); expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["settings", "overlay-config"], }); }); + + it("prefers a stored profile document over the admin defaults", async () => { + mocks.profileId = "profile-1"; + mocks.api.mockResolvedValue({ + enabled: true, + defaults: JSON.stringify({ + version: 2, + preset: "vibrant", + order: [], + items: {}, + }), + }); + mocks.effective = effectiveOverlayValue({ + version: 2, + preset: "minimal", + order: [], + items: {}, + }).data as Record; + + const { result } = renderHook(() => useOverlayPrefs(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.hasOverride).toBe(true); + expect(result.current.prefs?.preset).toBe("minimal"); + }); + + // A snapshot of today's server values would pin the profile to them; only + // deleting the stored document keeps it tracking later admin changes. + it("deletes the profile document so the profile follows the server defaults again", async () => { + mocks.profileId = "profile-1"; + mocks.api.mockResolvedValue({ + enabled: true, + defaults: JSON.stringify({ + version: 2, + preset: "vibrant", + order: [], + items: {}, + }), + }); + mocks.effective = effectiveOverlayValue({ + version: 2, + preset: "minimal", + order: [], + items: {}, + }).data as Record; + mocks.clearValue.mockResolvedValue(undefined); + + const { result, rerender } = renderHook(() => useOverlayPrefs(), { + wrapper: createWrapper(), + }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.resetPrefs(); + }); + + expect(mocks.clearValue).toHaveBeenCalledWith({ + key: SETTING_KEYS.UI_CARD_OVERLAYS, + identity: { scope: "profile" }, + }); + + // The effective read now resolves to the contract default (null), which is + // exactly what lets the admin document apply. + mocks.effective = effectiveOverlayValue(null).data as Record< + string, + { value: unknown } + >; + rerender(); + + expect(result.current.hasOverride).toBe(false); + expect(result.current.prefs?.preset).toBe("vibrant"); + }); + + it("treats a 404 from the canonical delete as already inheriting", async () => { + mocks.profileId = "profile-1"; + mocks.api.mockResolvedValue({ enabled: true }); + mocks.effective = effectiveOverlayValue(null).data as Record< + string, + { value: unknown } + >; + mocks.clearValue.mockRejectedValue( + new ApiClientError(404, "not_found", "no stored value"), + ); + + const { result } = renderHook(() => useOverlayPrefs(), { + wrapper: createWrapper(), + }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await expect(result.current.resetPrefs()).resolves.toBeUndefined(); + }); + }); + + it("surfaces a failed delete instead of pretending the reset landed", async () => { + mocks.profileId = "profile-1"; + mocks.api.mockResolvedValue({ enabled: true }); + mocks.effective = effectiveOverlayValue({ + version: 2, + preset: "minimal", + order: [], + items: {}, + }).data as Record; + mocks.clearValue.mockRejectedValue( + new ApiClientError(500, "server_error", "boom"), + ); + + const { result } = renderHook(() => useOverlayPrefs(), { + wrapper: createWrapper(), + }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await expect(result.current.resetPrefs()).rejects.toThrow("boom"); + }); + }); }); diff --git a/web/src/hooks/useOverlayPrefs.ts b/web/src/hooks/useOverlayPrefs.ts index edb4cbba4..f795ac3b9 100644 --- a/web/src/hooks/useOverlayPrefs.ts +++ b/web/src/hooks/useOverlayPrefs.ts @@ -1,12 +1,16 @@ import { useMemo, useCallback } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { api } from "@/api/client"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, ApiClientError } from "@/api/client"; import { + effectiveSettingsQueryKey, + isDefinitiveSettingMutationRejection, + useClearSettingValue, useEffectiveSettings, useSetSettingValue, + type EffectiveSettingsMap, } from "@/hooks/queries/settingValues"; import type { SettingIdentity } from "@/hooks/queries/settingValues"; -import { SETTING_KEYS } from "@/lib/settingsContract"; +import { SETTING_KEYS, type SettingKey } from "@/lib/settingsContract"; import { settingsKeys } from "@/hooks/queries/keys"; import { storage } from "@/utils/storage"; import { @@ -14,19 +18,37 @@ import { serializeOverlayPrefs, type CardOverlayPrefs, } from "@/lib/overlays"; +import { + normalizeCardQuickActionMode, + type EnabledCardQuickActionMode, +} from "@/lib/cardQuickActions"; -/** `ui.card_overlays` is profile-wide in the contract (no device scope). */ +/** Card overlay preferences are profile-wide in the contract (no device scope). */ const PROFILE_SCOPE: SettingIdentity = { scope: "profile" }; -const OVERLAY_KEYS = [SETTING_KEYS.UI_CARD_OVERLAYS] as const; +const OVERLAY_KEYS = [ + SETTING_KEYS.UI_CARD_OVERLAYS, + SETTING_KEYS.UI_CARD_OVERLAYS_ENABLED, + SETTING_KEYS.UI_CARD_QUICK_ACTIONS, + SETTING_KEYS.UI_CARD_QUICK_ACTIONS_ENABLED, +] as const; interface OverlayConfig { enabled: boolean; defaults?: string; + quick_actions_enabled?: boolean; + quick_actions_default?: string; +} + +// Overlay booleans are inherit-with-override, not a policy gate: the server +// setting is only the default for profiles that have not chosen, and an +// explicit profile choice wins in either direction. +function inheritBoolean(userValue: unknown, serverDefault: boolean): boolean { + return typeof userValue === "boolean" ? userValue : serverDefault; } -// The admin kill switch and server-wide defaults live in server_settings, not -// the user-settings contract, so this endpoint stays alongside the canonical +// The server-wide overlay defaults live in server_settings, not the +// user-settings contract, so this endpoint stays alongside the canonical // values API. function useOverlayConfig() { return useQuery({ @@ -39,17 +61,70 @@ function useOverlayConfig() { export function useOverlayPrefs() { // The effective endpoint requires a profile header; without one the user // has no stored preference and the admin defaults apply on their own. - const hasProfile = Boolean(storage.get(storage.KEYS.PROFILE_ID)); + const profileId = storage.get(storage.KEYS.PROFILE_ID); + const hasProfile = Boolean(profileId); const { data: effective, isLoading: userLoading } = useEffectiveSettings({ keys: OVERLAY_KEYS, enabled: hasProfile, }); const { data: config, isLoading: configLoading } = useOverlayConfig(); - const setValue = useSetSettingValue(); + const { mutate: setSettingValue } = useSetSettingValue(); + const clearValue = useClearSettingValue(); + const queryClient = useQueryClient(); + const effectiveQueryKey = useMemo( + () => + effectiveSettingsQueryKey({ + keys: OVERLAY_KEYS, + profileId: profileId ?? undefined, + }), + // The active profile is part of effectiveSettingsQueryKey. Recompute the + // target when profile selection changes rather than writing into the + // previous profile's cache entry. + [profileId], + ); + + const setProfileValue = useCallback( + (key: SettingKey, value: unknown) => { + // Writing a stored value back unchanged is a no-op: skip the network + // round-trip and the downstream re-render cascade. + if (effective?.[key]?.value === value) return; + queryClient.setQueryData( + effectiveQueryKey, + (current) => ({ + ...current, + [key]: { key, value, source: "profile", scope: "profile" }, + }), + ); + setSettingValue( + { key, value, identity: PROFILE_SCOPE }, + { + // The shared mutation invalidates on success and ambiguous errors. + // A definitive rejection never reached storage, so refetch instead of + // restoring a snapshot: rapid successive writes make any snapshot an + // unpersisted optimistic value, while a refetch reconciles the cache + // with what the server actually stored. + onError: (error) => { + if (isDefinitiveSettingMutationRejection(error)) { + void queryClient.invalidateQueries({ + queryKey: effectiveQueryKey, + }); + } + }, + }, + ); + }, + [effective, effectiveQueryKey, queryClient, setSettingValue], + ); // The contract default is null — "no preference expressed" — which is what // lets the server-wide admin default apply; a stored value wins outright. const userValue = effective?.[SETTING_KEYS.UI_CARD_OVERLAYS]?.value ?? null; + const overlaysEnabledUserValue = + effective?.[SETTING_KEYS.UI_CARD_OVERLAYS_ENABLED]?.value; + const quickActionUserValue = + effective?.[SETTING_KEYS.UI_CARD_QUICK_ACTIONS]?.value ?? null; + const quickActionsEnabledUserValue = + effective?.[SETTING_KEYS.UI_CARD_QUICK_ACTIONS_ENABLED]?.value; const prefs = useMemo(() => { // User setting takes priority; fall back to admin defaults @@ -57,8 +132,19 @@ export function useOverlayPrefs() { return parseOverlayPrefs(source); }, [userValue, config?.defaults]); - // Admin kill switch: if disabled server-wide, return null prefs - const enabled = config?.enabled !== false; + // Absent server config (including while it loads), overlays are on — the + // shipped default — and quick actions are off. + const overlaysEnabled = inheritBoolean( + overlaysEnabledUserValue, + config?.enabled !== false, + ); + const quickActionsEnabled = inheritBoolean( + quickActionsEnabledUserValue, + config?.quick_actions_enabled === true, + ); + const configuredQuickActionMode = normalizeCardQuickActionMode( + quickActionUserValue ?? config?.quick_actions_default, + ); const setPrefs = useCallback( (next: CardOverlayPrefs) => { @@ -72,24 +158,75 @@ export function useOverlayPrefs() { ) { return; } - setValue.mutate({ - key: SETTING_KEYS.UI_CARD_OVERLAYS, - value: next, - identity: PROFILE_SCOPE, - }); + setProfileValue(SETTING_KEYS.UI_CARD_OVERLAYS, next); }, - [userValue, setValue], + [userValue, setProfileValue], + ); + + const setOverlaysEnabled = useCallback( + (next: boolean) => + setProfileValue(SETTING_KEYS.UI_CARD_OVERLAYS_ENABLED, next), + [setProfileValue], + ); + + const setQuickActionMode = useCallback( + (next: EnabledCardQuickActionMode) => { + // Compare against the mode the control displays, not a differently + // normalized reading of the stored value: an unrecognized stored value + // displays the admin default, which must stay selectable. + if (quickActionUserValue != null && configuredQuickActionMode === next) + return; + setProfileValue(SETTING_KEYS.UI_CARD_QUICK_ACTIONS, next); + }, + [configuredQuickActionMode, quickActionUserValue, setProfileValue], + ); + + const setQuickActionsEnabled = useCallback( + (next: boolean) => + setProfileValue(SETTING_KEYS.UI_CARD_QUICK_ACTIONS_ENABLED, next), + [setProfileValue], + ); + + const resetPrefs = useCallback(async () => { + await Promise.all( + OVERLAY_KEYS.map(async (key) => { + try { + await clearValue.mutateAsync({ key, identity: PROFILE_SCOPE }); + } catch (error) { + // A missing scoped value already means this part of the preference + // is inheriting from the server default. + if (!(error instanceof ApiClientError && error.status === 404)) + throw error; + } + }), + ); + }, [clearValue]); + + const hasOverride = OVERLAY_KEYS.some( + (key) => effective?.[key]?.source === "profile", ); // While either query is in flight, report null prefs instead of built-in // defaults: rendering defaults first would flash badges that vanish (or - // change) the moment the user's own config or the admin kill switch loads. + // change) the moment the user's own config or the server default loads. const isLoading = (hasProfile && userLoading) || configLoading; return { - prefs: enabled && !isLoading ? prefs : null, + prefs: overlaysEnabled && !isLoading ? prefs : null, setPrefs, + overlaysEnabled, + setOverlaysEnabled, + quickActionMode: + quickActionsEnabled && !isLoading + ? configuredQuickActionMode + : ("none" as const), + quickActionPreference: configuredQuickActionMode, + setQuickActionMode, + quickActionsEnabled, + setQuickActionsEnabled, + resetPrefs, + hasOverride, + isResetting: clearValue.isPending, isLoading, - enabled, }; } diff --git a/web/src/hooks/useRestartKeys.test.ts b/web/src/hooks/useRestartKeys.test.ts new file mode 100644 index 000000000..4764dbe20 --- /dev/null +++ b/web/src/hooks/useRestartKeys.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { createRestartKeyMatcher } from "./useRestartKeys"; + +describe("createRestartKeyMatcher", () => { + it("matches keys listed exactly", () => { + const matcher = createRestartKeyMatcher({ + keys: ["auth.jwt_secret"], + prefixes: [], + }); + + expect(matcher.has("auth.jwt_secret")).toBe(true); + expect(matcher.has("auth.jwt_expiry")).toBe(false); + }); + + it("matches every key under a listed prefix", () => { + const matcher = createRestartKeyMatcher({ + keys: [], + prefixes: ["database.", "redis."], + }); + + expect(matcher.has("database.max_connections")).toBe(true); + expect(matcher.has("redis.url")).toBe(true); + // The prefix includes its trailing dot, so a sibling namespace that merely + // starts with the same word must not be badged. + expect(matcher.has("databases_extra.url")).toBe(false); + expect(matcher.has("branding.server_name")).toBe(false); + }); + + it("treats a missing or malformed payload as 'nothing needs a restart'", () => { + expect( + createRestartKeyMatcher(undefined).has("database.max_connections"), + ).toBe(false); + expect( + createRestartKeyMatcher({ keys: [], prefixes: [] }).has( + "database.max_connections", + ), + ).toBe(false); + // An older server can answer with nulls where the arrays should be. + const malformed = { keys: null, prefixes: null } as unknown as { + keys: string[]; + prefixes: string[]; + }; + expect(createRestartKeyMatcher(malformed).has("auth.jwt_secret")).toBe( + false, + ); + }); + + it("ignores empty strings so a blank prefix cannot match everything", () => { + const matcher = createRestartKeyMatcher({ + keys: ["", "s3.bucket"], + prefixes: [""], + }); + + expect(matcher.has("s3.bucket")).toBe(true); + expect(matcher.has("branding.server_name")).toBe(false); + }); +}); diff --git a/web/src/hooks/useRestartKeys.ts b/web/src/hooks/useRestartKeys.ts new file mode 100644 index 000000000..b0a4c89dc --- /dev/null +++ b/web/src/hooks/useRestartKeys.ts @@ -0,0 +1,53 @@ +import { useMemo } from "react"; + +import { + useAdminRestartKeys, + type RestartKeysResponse, +} from "@/hooks/queries/admin/settings"; + +/** + * Prefix-aware lookup over the server's restart-required registry. It is + * deliberately `Set`-shaped (`has(key)`) so call sites read the same whether + * the key is listed exactly or covered by a namespace prefix. + */ +export interface RestartKeyMatcher { + has(key: string): boolean; +} + +const EMPTY_MATCHER: RestartKeyMatcher = { has: () => false }; + +/** + * Builds a matcher from the endpoint payload. Anything that is not the + * expected `{ keys, prefixes }` shape — a loading query, or a server too old + * to serve the endpoint — degrades to "no key needs a restart" rather than to + * a broken page. + */ +export function createRestartKeyMatcher( + data: RestartKeysResponse | undefined, +): RestartKeyMatcher { + const exact = new Set( + Array.isArray(data?.keys) ? data.keys.filter(isNonEmptyString) : [], + ); + const prefixes = Array.isArray(data?.prefixes) + ? data.prefixes.filter(isNonEmptyString) + : []; + if (exact.size === 0 && prefixes.length === 0) return EMPTY_MATCHER; + return { + has: (key: string) => + exact.has(key) || prefixes.some((prefix) => key.startsWith(prefix)), + }; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value !== ""; +} + +/** + * Keys whose saved value only takes effect after a server restart. Feed it to + * `SettingField`'s `restartRequired` prop instead of writing "requires a + * restart" into hint text. + */ +export function useRestartKeys(): RestartKeyMatcher { + const { data } = useAdminRestartKeys(); + return useMemo(() => createRestartKeyMatcher(data), [data]); +} diff --git a/web/src/hooks/useSettingsForm.test.ts b/web/src/hooks/useSettingsForm.test.ts index 5ec80db71..a953472b4 100644 --- a/web/src/hooks/useSettingsForm.test.ts +++ b/web/src/hooks/useSettingsForm.test.ts @@ -3,6 +3,7 @@ import { act, cleanup, renderHook } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { useHasUnsavedChanges } from "./useUnsavedChanges"; import { useSettingsForm } from "./useSettingsForm"; const { mutateAsync } = vi.hoisted(() => ({ mutateAsync: vi.fn() })); @@ -150,3 +151,112 @@ describe("useSettingsForm save()", () => { expect(result.current.restartRequired).toBe(true); }); }); + +describe("useSettingsForm isClearStaged()", () => { + it("separates a staged clear from an untouched or replaced value", () => { + const { result } = renderHook(() => useSettingsForm({ keys: KEYS })); + + // An untouched empty key is not a clear: nothing would be written. + expect(result.current.isClearStaged("email.smtp_password")).toBe(false); + + act(() => { + result.current.setValue("branding.server_name", ""); + }); + expect(result.current.isClearStaged("branding.server_name")).toBe(true); + + act(() => { + result.current.setValue("branding.server_name", "Casa"); + }); + expect(result.current.isClearStaged("branding.server_name")).toBe(false); + + act(() => { + result.current.resetValue("branding.server_name"); + }); + expect(result.current.isClearStaged("branding.server_name")).toBe(false); + }); +}); + +describe("useSettingsForm unsaved-changes guard", () => { + function fireBeforeUnload(): Event { + // jsdom has no BeforeUnloadEvent, and its legacy `returnValue` is a + // boolean mirror of the canceled flag — `defaultPrevented` is the portable + // signal that the browser would prompt. + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + return event; + } + + it("does not warn while the form is clean", () => { + renderHook(() => useSettingsForm({ keys: KEYS })); + + expect(fireBeforeUnload().defaultPrevented).toBe(false); + }); + + it("warns before the page unloads with staged edits", () => { + const { result } = renderHook(() => useSettingsForm({ keys: KEYS })); + + act(() => { + result.current.setValue("branding.server_name", "Casa"); + }); + + expect(fireBeforeUnload().defaultPrevented).toBe(true); + }); + + it("stops warning once the edits are discarded", () => { + const { result } = renderHook(() => useSettingsForm({ keys: KEYS })); + + act(() => { + result.current.setValue("branding.server_name", "Casa"); + }); + act(() => { + result.current.discard(); + }); + + expect(fireBeforeUnload().defaultPrevented).toBe(false); + }); + + it("stops warning after the hook unmounts", () => { + const { result, unmount } = renderHook(() => + useSettingsForm({ keys: KEYS }), + ); + + act(() => { + result.current.setValue("branding.server_name", "Casa"); + }); + unmount(); + + expect(fireBeforeUnload().defaultPrevented).toBe(false); + }); + + // What `UnsavedChangesGuard` reads to block in-app navigation. The hook keeps + // no router dependency of its own; it only reports. + it("publishes staged edits to the shared unsaved-changes registry", () => { + const registry = renderHook(() => useHasUnsavedChanges()); + const form = renderHook(() => useSettingsForm({ keys: KEYS })); + + expect(registry.result.current).toBe(false); + + act(() => { + form.result.current.setValue("branding.server_name", "Casa"); + }); + expect(registry.result.current).toBe(true); + + act(() => { + form.result.current.discard(); + }); + expect(registry.result.current).toBe(false); + }); + + it("withdraws its registry claim when the form unmounts", () => { + const registry = renderHook(() => useHasUnsavedChanges()); + const form = renderHook(() => useSettingsForm({ keys: KEYS })); + + act(() => { + form.result.current.setValue("branding.server_name", "Casa"); + }); + expect(registry.result.current).toBe(true); + + form.unmount(); + expect(registry.result.current).toBe(false); + }); +}); diff --git a/web/src/hooks/useSettingsForm.ts b/web/src/hooks/useSettingsForm.ts index 226b6305d..5215f1a70 100644 --- a/web/src/hooks/useSettingsForm.ts +++ b/web/src/hooks/useSettingsForm.ts @@ -5,6 +5,7 @@ import { useUpdateServerSettings, useAdminSensitiveStatus, } from "@/hooks/queries/admin/settings"; +import { useReportUnsavedChanges } from "@/hooks/useUnsavedChanges"; interface UseSettingsFormOptions { /** Setting keys this section manages */ @@ -85,8 +86,39 @@ export function useSettingsForm({ keys }: UseSettingsFormOptions) { const dirtyCount = dirty.size; const dirtyKeys = useMemo(() => Array.from(dirty), [dirty]); + // In-app navigation is guarded by `UnsavedChangesGuard`, which blocks the + // router for as long as this registration is live. Reporting through a module + // store rather than owning the prompt keeps the hook usable where no guard is + // mounted (the setup wizard) and outside a router entirely. + useReportUnsavedChanges(dirtyCount > 0); + + // Every admin settings tab stages edits and only writes them through the + // SaveBar, so closing or reloading the tab would silently drop them. One + // guard here covers all tabs, and it is the only thing the browser lets us + // intercept: a tab close or reload never reaches the router. + useEffect(() => { + if (dirtyCount === 0) return; + function warnOnUnload(event: BeforeUnloadEvent) { + event.preventDefault(); + // Older browsers only show the prompt for a truthy returnValue; the text + // itself is ignored everywhere. + event.returnValue = ""; + } + window.addEventListener("beforeunload", warnOnUnload); + return () => window.removeEventListener("beforeunload", warnOnUnload); + }, [dirtyCount]); + const isDirty = useCallback((key: string) => dirty.has(key), [dirty]); + // A staged clear is a dirty empty value: the save batch writes "" and the + // server drops the stored value. This is what `SecretField`'s own clear + // affordance stages, and the one thing that distinguishes it from the + // "leave blank to keep the saved secret" default. + const isClearStaged = useCallback( + (key: string) => dirty.has(key) && getValue(key) === "", + [dirty, getValue], + ); + const buildConnectionCheckRequest = useCallback( (selectedKeys: string[] = keys): AdminSettingsConnectionCheckRequest => ({ values: Object.fromEntries( @@ -160,6 +192,7 @@ export function useSettingsForm({ keys }: UseSettingsFormOptions) { dirtyCount, dirtyKeys, isDirty, + isClearStaged, save, discard, isSaving: updateSettings.isPending, diff --git a/web/src/hooks/useTheme.previewIntent.test.tsx b/web/src/hooks/useTheme.previewIntent.test.tsx new file mode 100644 index 000000000..414c1137f --- /dev/null +++ b/web/src/hooks/useTheme.previewIntent.test.tsx @@ -0,0 +1,110 @@ +// @vitest-environment jsdom + +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/hooks/queries/settingValues", () => ({ + useEffectiveSettings: () => ({ data: undefined }), +})); + +vi.mock("@/hooks/queries/profileDefaults", () => ({ + useProfileDefaultWriter: () => ({ save: vi.fn() }), +})); + +vi.mock("@/hooks/useBranding", () => ({ + useBranding: () => ({ defaultTheme: null }), +})); + +// Only the auth-dependent hook is replaced; the parsing helpers around it are +// the real ones so this exercises the provider's actual resolution order. +vi.mock("@/hooks/themePreferences", async (importOriginal) => ({ + ...(await importOriginal()), + useAppearanceCacheOwner: () => null, +})); + +import { THEME_PREVIEW_INTENT_MS, ThemeProvider, useTheme } from "./useTheme"; + +function Probe() { + const { theme, previewTheme, resetPreviewTheme, setTheme } = useTheme(); + return ( + <> + {theme} + + + + + ); +} + +const domTheme = () => document.documentElement.getAttribute("data-theme"); + +describe("theme preview hover intent", () => { + beforeEach(() => { + localStorage.clear(); + vi.useFakeTimers(); + render( + + + , + ); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // A cursor crossing the swatch row on its way to another menu item used to + // flash the whole app light for a frame or two. + it("does not restyle the app for a hover shorter than the intent delay", () => { + const before = domTheme(); + + fireEvent.click(screen.getByRole("button", { name: "preview light" })); + + // Nothing is applied on the hover itself, nor part-way through the delay. + expect(domTheme()).toBe(before); + act(() => { + vi.advanceTimersByTime(THEME_PREVIEW_INTENT_MS / 2); + }); + expect(domTheme()).toBe(before); + + fireEvent.click(screen.getByRole("button", { name: "leave" })); + act(() => { + vi.advanceTimersByTime(1000); + }); + + // The armed preview is cancelled, not merely deferred. + expect(domTheme()).toBe(before); + }); + + it("previews once the pointer has settled for the intent delay", () => { + fireEvent.click(screen.getByRole("button", { name: "preview light" })); + act(() => { + vi.advanceTimersByTime(THEME_PREVIEW_INTENT_MS); + }); + + expect(domTheme()).toBe("cinema-light"); + + fireEvent.click(screen.getByRole("button", { name: "leave" })); + + expect(domTheme()).not.toBe("cinema-light"); + }); + + it("applies a picked theme immediately and drops any armed preview", () => { + fireEvent.click(screen.getByRole("button", { name: "preview light" })); + fireEvent.click(screen.getByRole("button", { name: "apply cobalt" })); + + expect(domTheme()).toBe("cobalt-studio"); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(domTheme()).toBe("cobalt-studio"); + }); +}); diff --git a/web/src/hooks/useTheme.tsx b/web/src/hooks/useTheme.tsx index f759cca55..84ecd8173 100644 --- a/web/src/hooks/useTheme.tsx +++ b/web/src/hooks/useTheme.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, + useRef, useState, useCallback, } from "react"; @@ -44,6 +45,32 @@ interface ThemeContextValue { const ThemeContext = createContext(null); +/** + * Hover intent for theme previews. A preview restyles the entire app, so a + * cursor merely crossing a row of swatches on its way somewhere else used to + * flash the whole UI — most visibly on the light theme. Arming the preview + * behind a short delay keeps a deliberate hover instant enough to feel live + * while a pass-through never flips anything. + */ +export const THEME_PREVIEW_INTENT_MS = 250; + +/** + * Whether an element was focused by keyboard rather than by the pointer. + * + * Radix moves DOM focus to the menu item under the cursor, so previewing on + * every focus would re-trigger exactly the flash the hover delay suppresses. + * Engines that do not know `:focus-visible` (jsdom included) simply never + * preview on focus, which is the safe direction to fail. + */ +export function isKeyboardFocus(element: Element | null | undefined): boolean { + if (!element) return false; + try { + return element.matches(":focus-visible"); + } catch { + return false; + } +} + /** * The four appearance keys this provider needs, fetched in one batched * effective read rather than a query per key. @@ -312,24 +339,51 @@ export function ThemeProvider({ children }: { children: ReactNode }) { applyHighContrastToDOM(highContrast); }, [highContrast]); + // Pending hover-intent timer for previewTheme. A ref rather than state: an + // armed preview is not something the tree renders, and re-rendering every + // swatch on hover is exactly the cost this is trying to avoid. + const previewTimerRef = useRef | null>(null); + + const cancelPendingPreview = useCallback(() => { + if (previewTimerRef.current !== null) { + clearTimeout(previewTimerRef.current); + previewTimerRef.current = null; + } + }, []); + + // A timer that outlives the provider would restyle a document nobody is + // looking at any more. + useEffect(() => cancelPendingPreview, [cancelPendingPreview]); + const setTheme = useCallback( (newTheme: ThemeId) => { + cancelPendingPreview(); setPreviewThemeState(null); setThemePreference(newTheme); applyThemeToDOM(newTheme); appearanceCache.set(storage.KEYS.THEME, newTheme, cacheOwner); void saveProfileDefault(SETTING_KEYS.UI_THEME, newTheme); }, - [saveProfileDefault, cacheOwner], + [saveProfileDefault, cacheOwner, cancelPendingPreview], ); - const previewTheme = useCallback((newTheme: ThemeId) => { - setPreviewThemeState(newTheme); - }, []); + // Arm the preview instead of applying it, so leaving within the intent window + // — the pass-through case — never repaints the app at all. + const previewTheme = useCallback( + (newTheme: ThemeId) => { + cancelPendingPreview(); + previewTimerRef.current = setTimeout(() => { + previewTimerRef.current = null; + setPreviewThemeState(newTheme); + }, THEME_PREVIEW_INTENT_MS); + }, + [cancelPendingPreview], + ); const resetPreviewTheme = useCallback(() => { + cancelPendingPreview(); setPreviewThemeState(null); - }, []); + }, [cancelPendingPreview]); const setTextScale = useCallback( (value: TextScale) => { diff --git a/web/src/hooks/useUnsavedChanges.test.ts b/web/src/hooks/useUnsavedChanges.test.ts new file mode 100644 index 000000000..b0eb8bcf5 --- /dev/null +++ b/web/src/hooks/useUnsavedChanges.test.ts @@ -0,0 +1,45 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { + useHasUnsavedChanges, + useReportUnsavedChanges, +} from "./useUnsavedChanges"; + +function fireBeforeUnload(): boolean { + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + return event.defaultPrevented; +} + +describe("useUnsavedChanges registry", () => { + it("arms the reload prompt while anything is dirty and disarms when clean", () => { + expect(fireBeforeUnload()).toBe(false); + + const report = renderHook(({ dirty }) => useReportUnsavedChanges(dirty), { + initialProps: { dirty: true }, + }); + const read = renderHook(() => useHasUnsavedChanges()); + + expect(read.result.current).toBe(true); + // The router guard cannot see reload/close; the registry's own + // beforeunload handler covers that path for every reporter. + expect(fireBeforeUnload()).toBe(true); + + report.rerender({ dirty: false }); + read.rerender(); + expect(read.result.current).toBe(false); + expect(fireBeforeUnload()).toBe(false); + + report.unmount(); + read.unmount(); + }); + + it("drops a reporter's claim on unmount", () => { + const report = renderHook(() => useReportUnsavedChanges(true)); + expect(fireBeforeUnload()).toBe(true); + + report.unmount(); + expect(fireBeforeUnload()).toBe(false); + }); +}); diff --git a/web/src/hooks/useUnsavedChanges.ts b/web/src/hooks/useUnsavedChanges.ts new file mode 100644 index 000000000..67d605655 --- /dev/null +++ b/web/src/hooks/useUnsavedChanges.ts @@ -0,0 +1,85 @@ +import { useEffect, useId, useSyncExternalStore } from "react"; + +/** + * Registry of forms that currently hold staged, unsaved edits. + * + * Draft state belongs to the component that owns the form (see + * `useSettingsForm`), so nothing above it can tell whether navigating away + * would throw work on the floor. Each form reports "I am dirty" here instead, + * and a guard mounted higher in the tree (`UnsavedChangesGuard`) turns that + * into a router-level block with a confirmation prompt. + * + * A module store rather than a context on purpose: the reporting side stays + * free of both a provider and the router, so `useSettingsForm` keeps working in + * places that mount no guard at all (the setup wizard) and in plain + * `renderHook` tests. + */ +const dirtySources = new Set(); +const listeners = new Set<() => void>(); + +function handleBeforeUnload(event: BeforeUnloadEvent) { + event.preventDefault(); + // Chrome still requires returnValue to be set for the prompt to appear. + event.returnValue = ""; +} + +// The router guard covers in-app navigation; reload and tab close never reach +// the router, so the registry arms the browser's own prompt whenever anything +// is dirty. Registering the same handler twice is a no-op, so this can run on +// every change. +function syncBeforeUnload() { + if (typeof window === "undefined") return; + if (dirtySources.size > 0) { + window.addEventListener("beforeunload", handleBeforeUnload); + } else { + window.removeEventListener("beforeunload", handleBeforeUnload); + } +} + +function emit() { + syncBeforeUnload(); + for (const listener of listeners) { + listener(); + } +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function hasUnsavedChangesSnapshot() { + return dirtySources.size > 0; +} + +/** + * Reports one form's dirty state to the registry. The entry is removed when the + * form goes clean (save or discard) and when the component unmounts, so a form + * can never leave a stale claim behind. + */ +export function useReportUnsavedChanges(hasUnsavedChanges: boolean) { + // One stable id per hook instance: two dirty pages must count as two + // sources, and remounting the same page must not leak the old entry. + const id = useId(); + + useEffect(() => { + if (!hasUnsavedChanges) return; + dirtySources.add(id); + emit(); + return () => { + dirtySources.delete(id); + emit(); + }; + }, [hasUnsavedChanges, id]); +} + +/** True while any mounted form has edits that were never saved. */ +export function useHasUnsavedChanges(): boolean { + return useSyncExternalStore( + subscribe, + hasUnsavedChangesSnapshot, + hasUnsavedChangesSnapshot, + ); +} diff --git a/web/src/lib/adminNavigation.ts b/web/src/lib/adminNavigation.ts index 12ca269e9..e105a6783 100644 --- a/web/src/lib/adminNavigation.ts +++ b/web/src/lib/adminNavigation.ts @@ -14,13 +14,12 @@ import { PanelsTopLeft, Puzzle, Radio, - RefreshCw, ScrollText, Send, Server, + Settings2, ShieldCheck, SkipForward, - SlidersHorizontal, Users, UsersRound, Wrench, @@ -32,7 +31,7 @@ import type { SettingsSearchGroup, SettingsSearchItem, } from "@/components/settings/settingsSearch"; -import { ADMIN_SETTINGS_GROUPS } from "@/lib/adminSettingsSearch"; +import { ADMIN_SETTINGS_NAV } from "@/lib/adminSettingsSearch"; import { pluginRouteHref } from "@/lib/pluginRouteHref"; export interface AdminNavItem extends SettingsSearchItem { @@ -96,25 +95,20 @@ export const ADMIN_NAV_SECTIONS: AdminNavGroup[] = [ { label: "Libraries", description: - "Media libraries, paths, scanning, and catalog import/export.", - keywords: ["library", "paths", "scan", "catalog", "seed"], - icon: Library, - href: "/admin/libraries", - }, - { - label: "Live TV", - description: - "HDHomeRun tuners, channel lineup, Schedules Direct, and XML sync guide sources.", + "Media libraries, paths, scanning, autoscan sources, and catalog import.", keywords: [ - "ota", - "hdhomerun", - "epg", - "dvr", - "guide", - "schedules direct", + "library", + "paths", + "scan", + "catalog", + "seed", + "autoscan", + "scan queue", + "polling", + "webhook source", ], - icon: Radio, - href: "/admin/livetv", + icon: Library, + href: "/admin/libraries", }, { label: "Collections", @@ -142,13 +136,6 @@ export const ADMIN_NAV_SECTIONS: AdminNavGroup[] = [ { label: "Automation", items: [ - { - label: "Autoscan", - description: "Autoscan sources, queue state, and poller behavior.", - keywords: ["scan queue", "cephfs", "polling", "matcher"], - icon: RefreshCw, - href: "/admin/autoscan", - }, { label: "Scheduled Tasks", description: "Background task schedules, runs, and job history.", @@ -157,7 +144,7 @@ export const ADMIN_NAV_SECTIONS: AdminNavGroup[] = [ href: "/admin/tasks", }, { - label: "Subtitles", + label: "Subtitle Files", description: "Downloaded subtitle records and subtitle admin tools.", keywords: ["captions", "subtitle downloads", "providers"], icon: Captions, @@ -229,16 +216,26 @@ export const ADMIN_NAV_SECTIONS: AdminNavGroup[] = [ ], }, { - label: "System", + label: "Settings", items: [ { label: "Settings", description: - "Server-wide settings, integrations, storage, and compatibility proxies.", - keywords: ["configuration", "server settings", "admin settings"], - icon: SlidersHorizontal, + "Server configuration, integrations, playback, storage, and access.", + keywords: [ + "settings", + "configuration", + "server settings", + "preferences", + ], + icon: Settings2, href: "/admin/settings", }, + ], + }, + { + label: "System", + items: [ { label: "Plugins", description: @@ -344,33 +341,30 @@ export function appendAdminPluginNavSection( ]; } -export function appendAdminSettingsNavSection( - sections: readonly AdminNavGroup[], -): AdminNavGroup[] { - return [ - ...sections.map((section) => ({ ...section, items: [...section.items] })), - { - label: "Admin Settings", - items: ADMIN_SETTINGS_GROUPS.flatMap((group) => - group.items.map((item) => ({ - label: item.label, - description: item.description, - keywords: ["admin settings", group.label, ...(item.keywords ?? [])], - settings: item.settings, - icon: item.icon, - href: `/admin/settings?tab=${encodeURIComponent(item.id)}`, - })), - ), - }, - ]; -} - export function buildAdminCommandNavSections( installations: readonly PluginInstallation[] | undefined, visibility: AdminNavVisibility = {}, ): AdminNavGroup[] { - return appendAdminPluginNavSection( - appendAdminSettingsNavSection(buildAdminNavSections(visibility)), - installations, + // Keep the persistent sidebar quiet while preserving direct access to every + // settings category and individual setting through Cmd+K. + const sections = buildAdminNavSections(visibility).map((section) => + section.label === "Settings" + ? { + ...section, + items: [ + ...section.items, + ...ADMIN_SETTINGS_NAV.map((item) => ({ + label: item.label, + description: item.description, + keywords: ["settings", "configuration", ...(item.keywords ?? [])], + settings: item.settings, + icon: item.icon, + href: `/admin/settings/${encodeURIComponent(item.id)}`, + })), + ], + } + : section, ); + + return appendAdminPluginNavSection(sections, installations); } diff --git a/web/src/lib/adminSettingsSearch.ts b/web/src/lib/adminSettingsSearch.ts index 74d9129d9..5210ee009 100644 --- a/web/src/lib/adminSettingsSearch.ts +++ b/web/src/lib/adminSettingsSearch.ts @@ -1,25 +1,16 @@ import { Bell, Captions, - Cloud, Database, Download, - Gauge, - HardDrive, - Info, - Layers, - Mail, - Image, - Network, + Library, Paintbrush, PlayCircle, - Puzzle, - Search, - ScanSearch, - ScrollText, + Plug, + RefreshCw, Settings2, Sparkles, - Subtitles, + Users, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; @@ -32,6 +23,8 @@ export interface AdminSettingsSearchItem extends SettingsSearchItem { id: string; label: string; description: string; + /** The named sections visible inside this settings destination. */ + groups: readonly string[]; keywords?: readonly string[]; settings?: readonly { label: string; @@ -39,6 +32,13 @@ export interface AdminSettingsSearchItem extends SettingsSearchItem { keywords?: readonly string[]; }[]; icon: LucideIcon; + /** + * Short qualifier rendered next to the label in a nav that wants one. The + * settings rail reads section health as a status dot instead, so nothing + * sets this today; the prop stays so a future nav can opt in without + * reshaping the item type. + */ + badge?: string; } export type AdminSettingsSearchGroup = @@ -47,65 +47,117 @@ export type AdminSettingsSearchGroup = const settingIndex = (...labels: string[]) => labels.map((label) => ({ label })); -// Tab ids are stable URL state (?tab=...) — regroup or reorder freely, but -// renaming an id breaks bookmarks and deep links. +// Page ids are stable route segments. Old ids from the 20-tab layout are kept +// working by LEGACY_ADMIN_SETTINGS_PAGE_ALIASES below — regroup or reorder +// freely, but add an alias entry whenever an id disappears. +// +// One group keeps the Overview cards and command palette in the same order. export const ADMIN_SETTINGS_GROUPS: AdminSettingsSearchGroup[] = [ { - label: "Server", + label: "Settings", items: [ { - id: "about", - label: "About", - description: "Server version, update status, and changelog.", + id: "general", + label: "General", + description: "Server identity, public signups, and logging.", + groups: ["Identity", "Access", "Logging"], keywords: [ - "version", - "build", - "update", - "changelog", - "release notes", - "revision", - "git", + "server name", + "login subtitle", + "signup", + "invite", + "log level", + "quiet", + "silenced log messages", + "branding name", ], settings: settingIndex( - "Version", - "Update status", - "Latest version", - "Changelog", + "Identity", + "Server name", + "Login subtitle", + "Access", + "Public signups", + "Logging", + "Log level", + "Quiet log prefixes", ), - icon: Info, + icon: Settings2, }, { - id: "general", - label: "General", - description: "Authentication, token lifetimes, and server logging.", + id: "infrastructure", + label: "Storage & Database", + description: + "Redis, S3 storage buckets, the database, and log retention.", + groups: [ + "Redis", + "Public storage", + "Private storage", + "Database", + "Logs", + ], keywords: [ - "access token", - "refresh token", - "expiry", - "log level", - "quiet subsystems", + "redis", + "s3", + "bucket", + "endpoint", + "region", + "access key", + "secret key", + "postgres", + "pool", + "user db", + "ops log", + "retention", + "decision log", + "opa", + "infrastructure", ], settings: settingIndex( - "Access Token Expiry", - "Refresh Token Expiry", - "Log Level", - "Quiet Subsystems", + "Redis", + "Use Redis", + "Connection URL", + "Public storage", + "Private storage", + "Endpoint", + "Region", + "Bucket", + "Access Key", + "Secret Key", + "Put the bucket name in the URL path", + "Folder inside the bucket", + "How asset links are authorized", + "Address clients download from", + "Token Secret", + "Token query parameter", + "Link lifetime (seconds)", + "Database", + "Maximum Postgres connections", + "Where per-user data is stored", + "Open files per user", + "Close idle user databases after", + "Logs", + "How much to record", + "Delete log entries older than (days)", + "Maximum log entries", + "Maximum log size (MB)", + "Delete permission records older than (days)", + "Record one allowed check in every", ), - icon: Settings2, + icon: Database, }, { - id: "branding", - label: "Branding", + id: "appearance", + label: "Appearance", description: - "White-label logo, favicon, server name, login background, and accent color.", + "Logos, accent color, default theme, custom CSS, and poster badges.", + groups: ["Logos and icons", "Colors and theme", "Card overlays"], keywords: [ "logo", "wordmark", - "icon", "favicon", + "login background", "white label", "brand", - "login background", "accent color", "default theme", "app name", @@ -113,242 +165,153 @@ export const ADMIN_SETTINGS_GROUPS: AdminSettingsSearchGroup[] = [ "light theme logo", "light wordmark", "light icon", + "theme", + "custom css", + "community themes", + "overlays", + "poster badges", ], settings: settingIndex( - "Server Name", - "Login Page Subtitle", + "Logos and icons", "Logo (wordmark)", "Logo (wordmark, light themes)", "Logo (icon)", "Logo (icon, light themes)", "Favicon", - "Login Background", - "Brand Accent Color", - "Default Theme", - ), - icon: Image, - }, - { - id: "theming", - label: "Theming", - description: - "Server theme token overrides, custom CSS, and the theme catalog.", - keywords: [ - "theme", - "custom css", - "community themes", - "appearance", - "token overrides", - ], - settings: settingIndex( - "Preview", - "Token Overrides", + "Login background", + "Colors and theme", + "Accent color", + "Custom accent color", + "Default theme", + "Individual colors and fonts", "Custom CSS", - "Theme Catalog URL", + "Community theme list", + "Card overlays", + "Show badges on poster art", + "Badge style", ), icon: Paintbrush, }, { - id: "overlays", - label: "Card Overlays", - description: "Server-wide poster badge and overlay defaults.", - keywords: [ - "poster", - "badges", - "defaults.card_overlays", - "overlay preset", - ], - settings: settingIndex( - "Card Overlays Enabled", - "Default Configuration", - "Default style preset", - "Overlay position", - "Overlay enabled", - ), - icon: Layers, - }, - ], - }, - { - label: "Media", - items: [ - { - id: "scanner", - label: "Scanner & Matcher", + id: "security", + label: "Security & Access", description: - "Scan workers, matcher workers, batch size, and image caching.", + "Sign-in sessions, trusted proxies, and request rate limits.", + groups: ["Sign-in sessions", "Network", "Rate limiting"], keywords: [ - "scanner workers", - "matcher workers", - "batch size", - "metadata cache images", + "access token", + "refresh token", + "expiry", + "session", + "proxy", + "x-forwarded-for", + "client ip", + "rate limit", + "throttle", + "429", + "api key tier", + "security", ], settings: settingIndex( - "Scanner Workers", - "Matcher Workers", - "Matcher Batch Size", - "Cache Images to S3", + "Sign-in sessions", + "Access token expiry", + "Refresh token expiry", + "Network", + "Trusted proxies", + "Rate limiting", + "Enable rate limiting", + "Where counters are kept", + "Whole-server requests per second", + "Per client address", + "Burst allowance", + "Standard API keys", + "Elevated API keys", ), - icon: ScanSearch, + icon: Users, }, { - id: "search", - label: "Search", + id: "library", + label: "Library & Metadata", description: - "Catalog search provider, Meilisearch connection, and index maintenance.", + "Artwork caching, scanning, intro and credits markers, and catalog search.", + groups: ["Metadata", "Scanning", "Intro and credits markers", "Search"], keywords: [ - "catalog search", - "meilisearch", - "postgres fts", - "full text search", - "index", - "typo tolerance", + "scanner workers", + "matcher", "batch size", - "index scope", - "semantic search", - "hybrid search", - "vectors", - "embeddings", - "embedder", - "binary quantization", - "quantized", - ], - settings: settingIndex( - "Preferred Provider", - "URL", - "API Key", - "Index Prefix", - "Timeout (ms)", - "Matching Strategy", - "Sync Batch Size", - "Rebuild Batch Size", - "Rebuild Queue Depth", - "Indexed Types", - "Semantic Search", - "Semantic Ratio", - "Embedder", - "Vectorized Documents", - "Status", - ), - icon: Search, - }, - { - id: "intro", - label: "Intro Markers", - description: - "Marker lookup mode, playback fetches, providers, and submissions.", - keywords: [ + "artwork", + "posters", + "cache images", + "object storage", "intro", "credits", "recap", "markers", - "chapter markers", - "provider contributions", - ], - settings: settingIndex( - "Mode", - "Fetch Markers at Playback if Missing", - "Use for Online Marker Lookup", - "Allow Contributions", - "Auto-submit Local Markers", - "Marker Providers", - ), - icon: Captions, - }, - { - id: "subtitles", - label: "Subtitles", - description: - "Downloaded subtitles, provider settings, and subtitle appearance.", - keywords: [ - "opensubtitles", - "providers", - "subtitle language", - "caption", - "downloaded", - ], - settings: settingIndex( - "Provider settings", - "Downloaded subtitles", - "Subtitle appearance", - "Subtitle language", - "Subtitle behavior", - "Forced subtitles", - ), - icon: Subtitles, - }, - { - id: "ai", - label: "AI Services", - description: - "AI provider endpoints, translation, transcription, and quotas.", - keywords: [ - "openai", - "ollama", - "base url", - "api key", - "chat model", - "translation", - "transcription", - "subtitles", - "quota", + "marker detection", + "meilisearch", + "postgres search", + "semantic", + "metadata", ], settings: settingIndex( - "Text translation", - "Base URL", - "Chat model", - "API Key", - "Test Text AI", - "Speech-to-text", - "Transcription model", - "Transcription base URL", - "Transcription API key", - "Test Speech-to-Text", - "Features", - "Max concurrent jobs", - "Subtitle translation", - "Subtitle generation from audio", - "Description translation", - "On-view translation", - "Subtitle batch size", - "Subtitle context lines", - "Transcription chunk length (seconds)", - "Transcription limit per account", - "Transcription limit period", - "Advanced", + "Metadata", + "S3 image caching", + "Scanning", + "Scanner workers", + "Matcher workers", + "Matcher batch size", + "Intro and credits markers", + "Find intros and credits", + "Fetch markers on playback", + "Populate markers", + "Contribute markers", + "Search", + "Search engine", + "Meilisearch URL", + "Meilisearch API key", + "Index name prefix", + "Query timeout (ms)", + "When a search has several words", + "Items sent to the index per batch", + "Match by meaning as well as words", + "Meaning-based share of results", + "Search status", ), - icon: Sparkles, + icon: Library, }, { id: "playback", label: "Playback", description: - "FFmpeg, transcoding, hardware acceleration, segments, and resume behavior.", + "Transcoding, hardware acceleration, and watch thresholds.", + groups: ["Transcoding", "Watch behavior"], keywords: [ "ffmpeg", "transcode", "hardware acceleration", + "gpu", "chapter thumbnails", "watched threshold", - "resume threshold", + "resume", "4k", ], settings: settingIndex( - "FFmpeg Path", - "Transcode Directory", - "Hardware Acceleration", - "Transcoding Enabled", - "Local Transcode Fallback", - "Allow 4K Transcoding", - "Enable Transcode Throttling", - "Throttle Buffer (seconds)", - "Chapter Thumbnail Workers", - "Chapter Thumbnail Execution", - "Chapter Thumbnail Node Capacity", - "HDR Chapter Thumbnail Policy", - "Enable CPU Tone Mapping", - "Watched Threshold (%)", - "Min Resume Threshold (%)", + "Transcoding", + "Hardware acceleration", + "Allow 4K transcoding", + "FFmpeg path", + "Transcode directory", + "GPU devices", + "Local transcode fallback", + "Throttle transcoding", + "Buffer ahead (seconds)", + "Chapter thumbnail workers", + "Generate chapter thumbnails on", + "HDR handling", + "Convert HDR colors on the CPU when the GPU cannot", + "Watch behavior", + "Mark watched at (%)", + "Show in Continue Watching after (%)", ), icon: PlayCircle, }, @@ -356,285 +319,250 @@ export const ADMIN_SETTINGS_GROUPS: AdminSettingsSearchGroup[] = [ id: "downloads", label: "Downloads", description: - "Download enablement, bandwidth, concurrency, and period limits.", + "Offline downloads, per-user and server-wide limits, and prepared copies.", + groups: ["Downloads"], keywords: [ + "downloads", + "offline", "bandwidth", - "concurrent downloads", - "download limit", - "period duration", + "mbps", + "throttle", + "quota", + "concurrency", + "prepared copies", + "artifacts", + "storage budget", ], settings: settingIndex( - "Downloads Enabled", - "Server Bandwidth (Mbps)", - "Per-User Bandwidth (Mbps)", - "Max Concurrent Downloads Per User", - "Max Downloads Per Period", - "Period Duration", + "Downloads", + "Allow downloads", + "Per-user bandwidth", + "Per user", + "Downloads at once per user", + "Downloads per period", + "Period length", + "Whole server", + "Server bandwidth", + "Prepare device-friendly copies", + "Prepared file directory", + "Files prepared at once", + "Prepared file storage budget", ), icon: Download, }, - ], - }, - { - label: "Connections", - items: [ { - id: "watch-providers", + id: "providers", + label: "Subtitles & Metadata", + description: + "Subtitle provider accounts, the MDBList metadata key, and marker providers.", + groups: [ + "Subtitle providers", + "Metadata providers", + "Marker providers", + ], + keywords: [ + "opensubtitles", + "subdl", + "subsource", + "mdblist", + "subtitles", + "captions", + "api key", + "provider credentials", + "integrations", + "theintrodb", + "introdb", + "marker providers", + "markers", + "intro", + "credits", + ], + settings: settingIndex( + "Subtitle providers", + "OpenSubtitles", + "SubDL", + "SubSource", + "Username", + "Password", + "API key", + "Metadata providers", + "MDBList", + "Marker providers", + "TheIntroDB", + "Use for online marker lookup", + "Lookup order", + "Allow contributions", + "Send this server's markers automatically", + "Minimum confidence", + ), + icon: Captions, + }, + { + id: "watch-sync", label: "Watch Providers", - description: "Provider integrations for watch history and scrobbling.", + description: + "Trakt, Simkl, and watch-provider plugins that profiles connect their own accounts to.", + groups: ["Watch providers"], keywords: [ "trakt", "simkl", - "import", - "export", "scrobble", - "watch history", - "favorites", + "watch providers", + "client id", + "client secret", + "sync", + "integrations", + "plugin", ], settings: settingIndex( - "Trakt Client ID", - "Trakt Client Secret", - "Simkl Client ID", - "Simkl Client Secret", + "Watch providers", + "Trakt", + "Simkl", + "Client ID", + "Client secret", ), - icon: Cloud, - }, - { - id: "integrations", - label: "Integrations", - description: "Third-party integration keys and service connections.", - keywords: ["mdblist", "api key", "metadata lists"], - settings: settingIndex("API Key"), - icon: Puzzle, + icon: RefreshCw, }, { - id: "email", - label: "Email", + id: "ai", + label: "AI Services", description: - "SMTP delivery, sender address, digest schedule, and external URL.", + "Text and speech-to-text models, and the features that spend them.", + groups: ["Models", "Features", "Usage and tuning"], keywords: [ - "smtp", - "mail", - "from address", - "digest", - "external url", - "tls", + "openai", + "ollama", + "groq", + "whisper", + "llm", + "model", + "translation", + "transcription", + "subtitle translation", + "integrations", ], settings: settingIndex( - "Email Enabled", - "From Address", - "From Name", - "Host", - "Port", - "Security", - "Username", - "Password", - "Verify", + "Models", + "Text model", + "Speech-to-text", + "Base URL", + "Model", + "API key", + "Features", + "Translate subtitles", + "Create subtitles from audio", + "Translate descriptions", + "Description translation for viewers", + "Jobs running at once", + "Subtitle lines per request", + "Surrounding lines sent for context", + "Audio sent per request (seconds)", + "Transcriptions per account", + "Allowance resets", ), - icon: Mail, + icon: Sparkles, }, { id: "notifications", label: "Notifications", description: - "Server notification channels, release events, Prairie Push Relay, Discord, web push, and webhooks.", + "Release events, delivery channels, the mail server, and webhooks.", + groups: ["Release events", "Delivery channels", "Tuning", "Retention"], keywords: [ "release events", "new episode", - "prairie push relay", + "email", + "smtp", + "mail", + "silo push relay", "mobile push", - "apple push", - "android push", "apns", - "push relay", - "privacy disclosure", + "fcm", "discord", - "browser push", "web push", "webhooks", "server channels", + "digest", ], settings: settingIndex( - "Record events", + "Notice new content", "Enable release events", - "Fan out", + "Work out who wants it", "Enable fanout", "Delivery Channels", "In-App", "Web Push", - "Prairie Push Relay", + "Silo Push Relay", "Relay URL", "Deployment ID", - "Register Relay", - "Clear Relay Credential", - "Privacy Disclosure", + "Apple Push (APNs)", + "Android Push (FCM)", "Email", - "Allow Per-Episode Email", - "Digest Hour", - "External URL", + "Send email from this server", + "From address", + "From name", + "Mail server address", + "Port", + "Encryption", + "Username", + "Password", + "Test email recipient", + "Let people pick an email per episode", + "Send the daily summary at", + "Link back to this server at", "Discord", "Client ID", - "Client Secret", - "Bot Token", - "Invite Bot to Server", - "Clear Discord Credentials", - "Allow Per-Episode DMs", - "Embed Posters", + "Client secret", + "Bot token", + "Let people pick a DM per episode", + "Show artwork in Discord messages", + "Mention the requester on Discord", "Personal Webhooks", - "Max Webhooks Per Profile", - "Deliveries Per Minute Per Profile", - "Allow Private Destinations", + "Webhooks each person may create", + "Webhook calls per minute, per person", + "Allow webhooks to private addresses", "Server Channels", - "Batch Window (seconds)", - "Mention Requesters on Discord", - "Settle Delay (seconds)", - "Max Series Burst", - "Max Event Age (hours)", - "Read Notifications (days)", - "Unread Notifications (days)", - "Processed Events (days)", + "Collect new items for (seconds)", + "Grouping and flood control", + "Wait before sending (seconds)", + "Most messages per show at once", + "Give up on content older than (hours)", + "How long notifications are kept", ), icon: Bell, }, { - id: "compatibility-proxies", - label: "Compatibility Proxies", + id: "compatibility", + label: "Compatibility", description: - "Jellyfin and Audiobookshelf compatibility proxy settings.", + "Jellyfin and Audiobookshelf client compatibility and the Jellyfin web player.", + groups: ["Jellyfin", "Audiobookshelf"], keywords: [ "jellyfin", "audiobookshelf", "abs", + "proxy", "public url", "server id", "session ttl", + "web player", ], settings: settingIndex( - "Public URL", - "Server Name", + "Jellyfin", + "Allow Jellyfin apps to connect", + "Address Jellyfin apps should use", + "Jellyfin Web install progress", + "Web player version to install", + "Web player install folder", + "Name shown to Jellyfin apps", "Server ID", - "Emulated Server Version", - "Session TTL", - "Playback Session TTL", - "Enable Audiobookshelf Proxy", - ), - icon: Network, - }, - { - id: "rate-limiting", - label: "Rate Limiting", - description: - "Request limits, API tiers, admin limits, and authentication throttles.", - keywords: [ - "limits", - "tiers", - "requests", - "throttle", - "429", - "api keys", - ], - settings: settingIndex( - "Enable Rate Limiting", - "Backend", - "Global Requests Per Second", - "Per-IP Limits", - "Requests / Second", - "Requests / Minute", - "Burst", - "Standard", - "Elevated", - "Login", - "Signup", - "Setup", - "Authentication endpoints", - ), - icon: Gauge, - }, - ], - }, - { - label: "Data", - items: [ - { - id: "database", - label: "Database", - description: - "Postgres, Redis, user database pooling, and Litestream settings.", - keywords: [ - "postgres", - "redis", - "connection url", - "user db", - "pool", - "litestream", - "stale grace", - ], - settings: settingIndex( - "Max Connections", - "Enable Redis", - "Connection URL", - "User DB Backend", - "Pool Max Open", - "Idle Timeout", - "Litestream Sync Interval", - "Stale Grace Seconds", - ), - icon: Database, - }, - { - id: "storage", - label: "Storage", - description: - "Public and private S3 storage buckets, endpoints, and credentials.", - keywords: [ - "s3", - "bucket", - "endpoint", - "region", - "access key", - "secret key", - "uploads", - ], - settings: settingIndex( - "Endpoint", - "Region", - "Path Style", - "Bucket", - "Access Key", - "Secret Key", - "URL Auth Method", - "Read Endpoint", - "Token Secret", - "Token Param", - "Token TTL (seconds)", + "Jellyfin version to report", + "Stay signed in for", + "Forget idle playback after", + "Audiobookshelf", + "Allow Audiobookshelf apps to connect", ), - icon: HardDrive, - }, - { - id: "log-retention", - label: "Log Retention", - description: - "Operations log cleanup, policy decision log cleanup, access log cleanup, and retention policies.", - keywords: [ - "ops log", - "access log", - "policy decision log", - "decision log", - "opa", - "cleanup", - "retention", - "history", - ], - settings: settingIndex( - "Retention Days", - "Max Rows", - "Max Size (MB)", - "Decision Log Retention Days", - "Decision Log Verbosity", - "Scope Sample Rate", - "Bucket Overrides", - ), - icon: ScrollText, + icon: Plug, }, ], }, @@ -643,3 +571,42 @@ export const ADMIN_SETTINGS_GROUPS: AdminSettingsSearchGroup[] = [ export const ADMIN_SETTINGS_NAV = ADMIN_SETTINGS_GROUPS.flatMap( (group) => group.items, ); + +const ADMIN_SETTINGS_PAGE_IDS = new Set( + ADMIN_SETTINGS_NAV.map((item) => item.id), +); + +/** + * Deep links from earlier layouts. Bookmarks, docs, and older client builds + * still point at these ids, so every one of them resolves to the page that + * absorbed it rather than falling through to the settings overview. + */ +export const LEGACY_ADMIN_SETTINGS_PAGE_ALIASES: Readonly< + Record +> = { + branding: "appearance", + theming: "appearance", + overlays: "appearance", + "rate-limiting": "security", + scanner: "library", + search: "library", + intro: "library", + subtitles: "providers", + integrations: "providers", + "watch-providers": "watch-sync", + email: "notifications", + jellyfin: "compatibility", + "compatibility-proxies": "compatibility", + database: "infrastructure", + storage: "infrastructure", + "log-retention": "infrastructure", +}; + +/** Resolves a route segment or legacy `?tab=` value to a current page id. */ +export function resolveAdminSettingsPageID( + value: string | null, +): string | null { + if (!value) return null; + if (ADMIN_SETTINGS_PAGE_IDS.has(value)) return value; + return LEGACY_ADMIN_SETTINGS_PAGE_ALIASES[value] ?? null; +} diff --git a/web/src/lib/cardQuickActions.test.ts b/web/src/lib/cardQuickActions.test.ts new file mode 100644 index 000000000..caf42cfb4 --- /dev/null +++ b/web/src/lib/cardQuickActions.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { CARD_QUICK_ACTION_MODES } from "@/lib/cardQuickActions"; +import { SETTING_DEFINITIONS, SETTING_KEYS } from "@/lib/settingsContract"; + +// The mode literals exist only because the generated definition types its +// members as unknown, which cannot produce the literal union the card props +// need. This assertion is what keeps the hand-written copy honest; the option +// labels derive from the contract directly. +describe("card quick action modes", () => { + it("lists exactly the contract's enum members, in manifest order", () => { + const members = + SETTING_DEFINITIONS[SETTING_KEYS.UI_CARD_QUICK_ACTIONS].values ?? []; + expect(CARD_QUICK_ACTION_MODES).toEqual( + members.map((member) => member.value), + ); + }); +}); diff --git a/web/src/lib/cardQuickActions.ts b/web/src/lib/cardQuickActions.ts new file mode 100644 index 000000000..c0a2556f0 --- /dev/null +++ b/web/src/lib/cardQuickActions.ts @@ -0,0 +1,42 @@ +import { SETTING_DEFINITIONS, SETTING_KEYS } from "@/lib/settingsContract"; + +// Hand-written only because the generated definition types its enum members as +// unknown, which cannot produce the literal union the card props need; the test +// pins this list to the contract. +export const CARD_QUICK_ACTION_MODES = [ + "both", + "favorites", + "watched", +] as const; + +export type EnabledCardQuickActionMode = + (typeof CARD_QUICK_ACTION_MODES)[number]; +export type CardQuickActionMode = EnabledCardQuickActionMode | "none"; + +export const CARD_QUICK_ACTION_OPTIONS: ReadonlyArray<{ + value: EnabledCardQuickActionMode; + label: string; +}> = (SETTING_DEFINITIONS[SETTING_KEYS.UI_CARD_QUICK_ACTIONS].values ?? []).map( + (member) => ({ + value: member.value as EnabledCardQuickActionMode, + label: member.label, + }), +); + +export function normalizeCardQuickActionMode( + value: unknown, + fallback: EnabledCardQuickActionMode = "both", +): EnabledCardQuickActionMode { + return typeof value === "string" && + CARD_QUICK_ACTION_MODES.includes(value as EnabledCardQuickActionMode) + ? (value as EnabledCardQuickActionMode) + : fallback; +} + +export function showsFavoriteQuickAction(mode: CardQuickActionMode): boolean { + return mode === "both" || mode === "favorites"; +} + +export function showsWatchedQuickAction(mode: CardQuickActionMode): boolean { + return mode === "both" || mode === "watched"; +} diff --git a/web/src/lib/emailReadiness.ts b/web/src/lib/emailReadiness.ts new file mode 100644 index 000000000..cb54e03f5 --- /dev/null +++ b/web/src/lib/emailReadiness.ts @@ -0,0 +1,15 @@ +/** + * Whether outbound mail can actually send, mirrored from the server's own + * validation (`internal/config/admin_settings.go`): the switch on, a host, AND + * a sender address — enabling email with no from-address is rejected there, + * but legacy rows and single-key writes can still leave that state stored. + * One rule for every surface (settings page, overview tile) so they cannot + * drift into disagreeing about readiness. + */ +export function emailReady( + enabled: boolean, + smtpHost: string, + fromAddress: string, +): boolean { + return enabled && smtpHost.trim() !== "" && fromAddress.trim() !== ""; +} diff --git a/web/src/lib/keyboardShortcut.test.ts b/web/src/lib/keyboardShortcut.test.ts new file mode 100644 index 000000000..10a098524 --- /dev/null +++ b/web/src/lib/keyboardShortcut.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { searchShortcutLabel } from "./keyboardShortcut"; + +function stubUserAgent(value: string) { + vi.spyOn(window.navigator, "userAgent", "get").mockReturnValue(value); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("searchShortcutLabel", () => { + it("uses the command glyph on Apple keyboards", () => { + stubUserAgent( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15", + ); + expect(searchShortcutLabel()).toBe("⌘ K"); + + stubUserAgent( + "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15", + ); + expect(searchShortcutLabel()).toBe("⌘ K"); + }); + + it("spells out Ctrl everywhere else", () => { + stubUserAgent( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + ); + expect(searchShortcutLabel()).toBe("Ctrl K"); + + stubUserAgent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"); + expect(searchShortcutLabel()).toBe("Ctrl K"); + + stubUserAgent("Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36"); + expect(searchShortcutLabel()).toBe("Ctrl K"); + }); +}); diff --git a/web/src/lib/keyboardShortcut.ts b/web/src/lib/keyboardShortcut.ts new file mode 100644 index 000000000..8d2e70268 --- /dev/null +++ b/web/src/lib/keyboardShortcut.ts @@ -0,0 +1,15 @@ +/** + * How the search shortcut is spelled on this machine: the ⌘ glyph on Apple + * keyboards, "Ctrl" everywhere else. Every surface that advertises the + * ⌘K / Ctrl-K search shortcut reads it from here, so a Windows or Linux admin + * is never told to press a key their keyboard does not have. + * + * The handlers themselves accept either modifier — this is a label, not the + * binding. + */ +export function searchShortcutLabel(): string { + const isApple = + typeof navigator !== "undefined" && + /Mac|iPhone|iPad|iPod/.test(navigator.userAgent); + return isApple ? "⌘ K" : "Ctrl K"; +} diff --git a/web/src/lib/pluginConfigReady.test.ts b/web/src/lib/pluginConfigReady.test.ts new file mode 100644 index 000000000..ce59e3c2a --- /dev/null +++ b/web/src/lib/pluginConfigReady.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import type { PluginInstallation } from "@/api/types"; + +import { installationConfigReady } from "./pluginConfigReady"; + +function installation( + overrides: Partial, +): PluginInstallation { + return { + id: 1, + repository_id: 1, + plugin_id: "silo.test", + version: "1.0.0", + install_path: "/plugins/test", + enabled: true, + source_kind: "silo", + updates_paused: false, + capabilities: [], + global_config_schema: [], + user_config_schema: [], + routes: [], + assets: [], + global_configs: [], + auth_bindings: [], + task_bindings: [], + update_policy: "auto", + ...overrides, + } as PluginInstallation; +} + +const schema = (key: string, required: boolean) => ({ + key, + title: key, + json_schema: "{}", + required, +}); + +describe("installationConfigReady", () => { + it("is ready when the plugin declares no configuration", () => { + expect(installationConfigReady(installation({}))).toBe(true); + }); + + it("is not ready when configuration is declared but nothing is saved", () => { + expect( + installationConfigReady( + installation({ + global_config_schema: [schema("account", true)], + global_configs: [], + }), + ), + ).toBe(false); + }); + + it("counts configured secrets as filled", () => { + expect( + installationConfigReady( + installation({ + global_config_schema: [schema("account", true)], + global_configs: [ + { key: "account", value: {}, configured_secrets: ["api_key"] }, + ], + }), + ), + ).toBe(true); + }); + + it("counts a required boolean explicitly saved as false as configured", () => { + // `false` is a value the admin deliberately chose; only a blank string is + // "no value" (that exclusion keeps a keyless plugin from reading ready). + expect( + installationConfigReady( + installation({ + global_config_schema: [schema("advanced", true)], + global_configs: [ + { + key: "advanced", + value: { advanced: false }, + configured_secrets: [], + }, + ], + }), + ), + ).toBe(true); + }); + + it("does not count a blank string as a value", () => { + expect( + installationConfigReady( + installation({ + global_config_schema: [schema("account", true)], + global_configs: [ + { + key: "account", + value: { api_key: " " }, + configured_secrets: [], + }, + ], + }), + ), + ).toBe(false); + }); +}); diff --git a/web/src/lib/pluginConfigReady.ts b/web/src/lib/pluginConfigReady.ts new file mode 100644 index 000000000..319d2c4d6 --- /dev/null +++ b/web/src/lib/pluginConfigReady.ts @@ -0,0 +1,42 @@ +import type { PluginInstallation } from "@/api/types"; + +/** + * Whether a plugin installation has the global configuration it asks for. + * + * Silo never sees a plugin's secrets — they live in the plugin's own runtime + * config — but the installation response says which config keys the plugin + * declares and which have a value or configured secret saved. That is enough + * to stop a provider with no API key from reading as connected, which is the + * shared rule for every provider tile backed by a plugin: "Connected" means + * the plugin could actually serve a configured request. + */ +export function installationConfigReady( + installation: PluginInstallation, +): boolean { + const schema = installation.global_config_schema ?? []; + if (schema.length === 0) return true; + + const saved = new Map( + (installation.global_configs ?? []).map((config) => [config.key, config]), + ); + const filled = (key: string) => { + const config = saved.get(key); + if (!config) return false; + if ((config.configured_secrets ?? []).length > 0) return true; + // Any saved non-null value counts — `false` and `0` are values an admin + // deliberately chose (a required boolean saved as false is configured). + // Only a blank string is excluded: an empty text box is not a value, and + // counting it would let a keyless plugin read as connected. + return Object.values(config.value ?? {}).some((value) => + typeof value === "string" ? value.trim() !== "" : value != null, + ); + }; + + // A plugin that takes configuration and has none saved is not set up, whether + // or not it marked a field required — several plugins declare everything + // optional and then fail every lookup without a key. + if (!schema.some((entry) => filled(entry.key))) return false; + return schema + .filter((entry) => entry.required) + .every((entry) => filled(entry.key)); +} diff --git a/web/src/lib/queryInvalidation.test.ts b/web/src/lib/queryInvalidation.test.ts index d9ec7e1b3..e45317155 100644 --- a/web/src/lib/queryInvalidation.test.ts +++ b/web/src/lib/queryInvalidation.test.ts @@ -34,12 +34,33 @@ describe("activeSectionQueryMatchesLibrary", () => { ).toBe(false); }); - it("matches non-library and same-library section queries", () => { + it("matches same-library and non-library-scoped section queries", () => { expect( - activeSectionQueryMatchesLibrary(["sections", "home", "items"], 3), + activeSectionQueryMatchesLibrary(["sections", "library", 3, "layout"], 3), ).toBe(true); expect( - activeSectionQueryMatchesLibrary(["sections", "library", 3, "layout"], 3), + activeSectionQueryMatchesLibrary( + ["sections", "admin", "home", undefined], + 3, + ), + ).toBe(true); + }); + + it("does not match home section queries for a library-scoped event", () => { + expect( + activeSectionQueryMatchesLibrary(["sections", "home", "layout"], 3), + ).toBe(false); + expect( + activeSectionQueryMatchesLibrary( + ["sections", "home", "items", "recent"], + 3, + ), + ).toBe(false); + }); + + it("matches home section queries when the event is not library-scoped", () => { + expect( + activeSectionQueryMatchesLibrary(["sections", "home", "items", "recent"]), ).toBe(true); }); }); diff --git a/web/src/lib/queryInvalidation.ts b/web/src/lib/queryInvalidation.ts index 1c02690da..86045cd26 100644 --- a/web/src/lib/queryInvalidation.ts +++ b/web/src/lib/queryInvalidation.ts @@ -8,12 +8,23 @@ export function activeCatalogQueryMatchesLibrary( return params?.library_id == null || params.library_id === libraryId; } +/** + * Decides whether a `["sections", …]` query belongs to the library an event + * changed. + * + * Home sections are deliberately excluded: they are not library-scoped, so a + * scan touching one library used to invalidate every home row on every event. + * Home rerenders from cache and refreshes on its next mount, which is cheap; + * refetching the whole home layout thousands of times during a scan is not. + */ export function activeSectionQueryMatchesLibrary( queryKey: unknown, libraryId?: number, ) { if (!libraryId || !Array.isArray(queryKey)) return true; - if (queryKey[0] !== "sections" || queryKey[1] !== "library") return true; + if (queryKey[0] !== "sections") return true; + if (queryKey[1] === "home") return false; + if (queryKey[1] !== "library") return true; const queryLibraryId = queryKey[2]; return queryLibraryId == null || queryLibraryId === libraryId; } diff --git a/web/src/lib/routeChunkPrefetch.test.ts b/web/src/lib/routeChunkPrefetch.test.ts new file mode 100644 index 000000000..99e88c725 --- /dev/null +++ b/web/src/lib/routeChunkPrefetch.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from "vitest"; +import { + prefetchRouteChunks, + type RouteChunkScheduler, +} from "./routeChunkPrefetch"; + +/** Collects scheduled tasks so a test drives the idle queue explicitly. */ +function manualScheduler() { + const tasks: Array<() => void> = []; + let cancelledCount = 0; + const schedule: RouteChunkScheduler = (task) => { + tasks.push(task); + return () => { + cancelledCount += 1; + }; + }; + return { + schedule, + cancelledCount: () => cancelledCount, + pending: () => tasks.length, + async runNext() { + const task = tasks.shift(); + task?.(); + // Let the import promise and its `finally` continuation settle so the + // next warm-up is queued before the assertion runs. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }, + }; +} + +describe("prefetchRouteChunks", () => { + it("warms one chunk per idle window, in order", async () => { + const order: string[] = []; + const scheduler = manualScheduler(); + + prefetchRouteChunks( + [ + () => { + order.push("first"); + return Promise.resolve(); + }, + () => { + order.push("second"); + return Promise.resolve(); + }, + ], + scheduler.schedule, + ); + + expect(order).toEqual([]); + + await scheduler.runNext(); + expect(order).toEqual(["first"]); + + await scheduler.runNext(); + expect(order).toEqual(["first", "second"]); + expect(scheduler.pending()).toBe(0); + }); + + it("continues past a chunk that fails to load", async () => { + const second = vi.fn(() => Promise.resolve()); + const scheduler = manualScheduler(); + + prefetchRouteChunks( + [() => Promise.reject(new Error("offline")), second], + scheduler.schedule, + ); + + await scheduler.runNext(); + await scheduler.runNext(); + + expect(second).toHaveBeenCalledOnce(); + }); + + it("stops warming after cancellation", async () => { + const second = vi.fn(() => Promise.resolve()); + const scheduler = manualScheduler(); + + const cancel = prefetchRouteChunks( + [() => Promise.resolve(), second], + scheduler.schedule, + ); + + await scheduler.runNext(); + expect(scheduler.pending()).toBe(1); + + cancel(); + await scheduler.runNext(); + + expect(second).not.toHaveBeenCalled(); + expect(scheduler.cancelledCount()).toBe(1); + }); +}); diff --git a/web/src/lib/routeChunkPrefetch.ts b/web/src/lib/routeChunkPrefetch.ts new file mode 100644 index 000000000..f9265c2cf --- /dev/null +++ b/web/src/lib/routeChunkPrefetch.ts @@ -0,0 +1,67 @@ +/** + * Idle-time warm-up for the hottest lazy route chunks. + * + * Code-splitting the routes keeps the bootstrap bundle small, but it also puts + * a network round trip in front of the first navigation to each route: the old + * page stays frozen (or falls back to the app-level Suspense boundary) while + * the chunk downloads. Warming the handful of routes practically every session + * reaches keeps the split and removes that stall. + * + * Chunks are fetched one at a time, each on its own idle callback, so the + * warm-up never competes with the first screen's data requests. + */ +export type RouteChunkImport = () => Promise; + +/** Upper bound on how long a warm-up may sit waiting for an idle window. */ +export const ROUTE_CHUNK_IDLE_TIMEOUT_MS = 2_000; + +/** Schedules `task` and returns a canceller for it. */ +export type RouteChunkScheduler = (task: () => void) => () => void; + +/** + * Fetches each import in order, one per idle window. Returns a canceller that + * stops the remaining warm-ups; imports already in flight are left alone since + * their result lands in the module cache either way. + */ +export function prefetchRouteChunks( + imports: readonly RouteChunkImport[], + schedule: RouteChunkScheduler = scheduleWhenIdle, +): () => void { + let cancelled = false; + let cancelPending: (() => void) | null = null; + + const warmFrom = (index: number) => { + const load = imports[index]; + if (cancelled || !load) return; + cancelPending = schedule(() => { + cancelPending = null; + if (cancelled) return; + void load() + .catch(() => { + // A failed warm-up is not an error worth reporting: the route retries + // the import on navigation, where React.lazy surfaces the failure to + // the error boundary with the user actually waiting on it. + }) + .finally(() => warmFrom(index + 1)); + }); + }; + + warmFrom(0); + + return () => { + cancelled = true; + cancelPending?.(); + cancelPending = null; + }; +} + +function scheduleWhenIdle(task: () => void): () => void { + if (typeof globalThis.requestIdleCallback === "function") { + const handle = globalThis.requestIdleCallback(() => task(), { + timeout: ROUTE_CHUNK_IDLE_TIMEOUT_MS, + }); + return () => globalThis.cancelIdleCallback?.(handle); + } + const handle = globalThis.setTimeout(task, ROUTE_CHUNK_IDLE_TIMEOUT_MS); + return () => globalThis.clearTimeout(handle); +} diff --git a/web/src/lib/settingsConformance.json b/web/src/lib/settingsConformance.json index 63fb19d7b..bcf2146e1 100644 --- a/web/src/lib/settingsConformance.json +++ b/web/src/lib/settingsConformance.json @@ -1,6 +1,6 @@ { "fixture_version": 1, - "manifest_revision": 7, + "manifest_revision": 8, "description": "Cross-platform conformance cases for settings resolution. Every case runs against the shipped manifest in this directory: definitions are referenced by key, never restated, so an expectation can only be satisfied by resolving the real contract. Each platform's resolver (Go in internal/settingsresolve, TypeScript in web/src/lib/settingsResolve.ts, Kotlin and Swift in the client repos) runs every case through a hand-written runner; a runner must fail on any fixture field it does not know, because schema drift in the fixture itself is drift. A case's constraint_bindings attach a constraint to a copy of a real definition so constraint semantics stay testable even while no shipped definition carries that constraint kind. In expected entries, constrained:true requires stored_value and constraint_kind to be present, and stored_value may be null to mean the authored value was JSON null.", "cases": [ { @@ -733,6 +733,149 @@ "source": "default" } ] + }, + { + "name": "card_overlays_enabled_profile_opt_out", + "description": "A profile that turned its own overlay badges off resolves false at profile scope; the server-wide default is not part of settings resolution.", + "keys": ["ui.card_overlays_enabled"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "ui.card_overlays_enabled", + "scope": "profile", + "profile_id": "p1", + "value": false + } + ], + "expected": [ + { + "key": "ui.card_overlays_enabled", + "value": false, + "source": "profile" + } + ] + }, + { + "name": "card_overlays_enabled_profile_opt_in", + "description": "A profile that turned its own overlay badges on resolves true at profile scope, which is what lets it override a server-wide default of off.", + "keys": ["ui.card_overlays_enabled"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "ui.card_overlays_enabled", + "scope": "profile", + "profile_id": "p1", + "value": true + } + ], + "expected": [ + { + "key": "ui.card_overlays_enabled", + "value": true, + "source": "profile" + } + ] + }, + { + "name": "card_overlays_absent_express_no_preference", + "description": "Nothing stored resolves both card-overlay keys to null with source \"default\", which is what lets the server-wide admin defaults apply.", + "keys": ["ui.card_overlays", "ui.card_overlays_enabled"], + "context": { "profile_id": "p1" }, + "stored": [], + "expected": [ + { + "key": "ui.card_overlays", + "value": null, + "source": "default" + }, + { + "key": "ui.card_overlays_enabled", + "value": null, + "source": "default" + } + ] + }, + { + "name": "card_quick_actions_profile_choice_wins", + "description": "A profile that picked favorites-only shortcuts resolves at profile scope instead of falling through to the server-wide default.", + "keys": ["ui.card_quick_actions"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "ui.card_quick_actions", + "scope": "profile", + "profile_id": "p1", + "value": "favorites" + } + ], + "expected": [ + { + "key": "ui.card_quick_actions", + "value": "favorites", + "source": "profile" + } + ] + }, + { + "name": "card_quick_actions_enabled_profile_opt_out", + "description": "A profile that turned its own quick actions off resolves false at profile scope; the server-wide default is not part of settings resolution.", + "keys": ["ui.card_quick_actions_enabled"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "ui.card_quick_actions_enabled", + "scope": "profile", + "profile_id": "p1", + "value": false + } + ], + "expected": [ + { + "key": "ui.card_quick_actions_enabled", + "value": false, + "source": "profile" + } + ] + }, + { + "name": "card_quick_actions_enabled_profile_opt_in", + "description": "A profile that turned its own quick actions on resolves true at profile scope, which is what lets it override a server-wide default of off.", + "keys": ["ui.card_quick_actions_enabled"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "ui.card_quick_actions_enabled", + "scope": "profile", + "profile_id": "p1", + "value": true + } + ], + "expected": [ + { + "key": "ui.card_quick_actions_enabled", + "value": true, + "source": "profile" + } + ] + }, + { + "name": "card_quick_actions_absent_express_no_preference", + "description": "Nothing stored resolves both quick-action keys to null with source \"default\", which is what lets the server-wide admin defaults apply.", + "keys": ["ui.card_quick_actions", "ui.card_quick_actions_enabled"], + "context": { "profile_id": "p1" }, + "stored": [], + "expected": [ + { + "key": "ui.card_quick_actions", + "value": null, + "source": "default" + }, + { + "key": "ui.card_quick_actions_enabled", + "value": null, + "source": "default" + } + ] } ] } diff --git a/web/src/lib/settingsContract.ts b/web/src/lib/settingsContract.ts index d3404a36e..859742445 100644 --- a/web/src/lib/settingsContract.ts +++ b/web/src/lib/settingsContract.ts @@ -9,7 +9,7 @@ */ export const SETTINGS_API_VERSION = 1; -export const SETTINGS_REVISION = 7; +export const SETTINGS_REVISION = 8; export interface SettingSuggestedOption { value: string; @@ -231,8 +231,14 @@ export const SETTING_KEYS = { SUBTITLE_MATCHES_DEVICE: "subtitle.matches_device", /** Poster badges */ UI_CARD_OVERLAYS: "ui.card_overlays", + /** Card overlays enabled */ + UI_CARD_OVERLAYS_ENABLED: "ui.card_overlays_enabled", /** Media cards */ UI_CARD_PRESENTATION: "ui.card_presentation", + /** Card quick actions */ + UI_CARD_QUICK_ACTIONS: "ui.card_quick_actions", + /** Card quick actions enabled */ + UI_CARD_QUICK_ACTIONS_ENABLED: "ui.card_quick_actions_enabled", /** Custom CSS */ UI_CUSTOM_CSS: "ui.custom_css", /** Custom theme variables */ @@ -1040,6 +1046,22 @@ export const SETTING_DEFINITIONS: Record = { category: "appearance", platforms: ["web", "ios", "tvos", "macos", "android", "android_tv"], }, + "ui.card_overlays_enabled": { + key: "ui.card_overlays_enabled", + type: "boolean", + nullable: true, + persistence: "remote", + introducedIn: 8, + scopes: ["profile"], + scopeIntroducedIn: [8], + resolutionOrder: ["profile", "default"], + defaultValue: null, + label: "Card overlays enabled", + description: "Show overlay badges on media cards.", + category: "appearance", + control: "switch", + platforms: ["web"], + }, "ui.card_presentation": { key: "ui.card_presentation", type: "object", @@ -1056,6 +1078,44 @@ export const SETTING_DEFINITIONS: Record = { control: "panel", platforms: ["web", "ios", "tvos", "macos", "android", "android_tv"], }, + "ui.card_quick_actions": { + key: "ui.card_quick_actions", + type: "enum", + nullable: true, + persistence: "remote", + introducedIn: 8, + scopes: ["profile"], + scopeIntroducedIn: [8], + resolutionOrder: ["profile", "default"], + defaultValue: null, + label: "Card quick actions", + description: "Which favorite and watched shortcuts appear on media cards.", + category: "appearance", + control: "select", + platforms: ["web"], + values: [ + { value: "both", label: "Both", introducedIn: 8 }, + { value: "favorites", label: "Favorites only", introducedIn: 8 }, + { value: "watched", label: "Watch indicator only", introducedIn: 8 }, + ], + }, + "ui.card_quick_actions_enabled": { + key: "ui.card_quick_actions_enabled", + type: "boolean", + nullable: true, + persistence: "remote", + introducedIn: 8, + scopes: ["profile"], + scopeIntroducedIn: [8], + resolutionOrder: ["profile", "default"], + defaultValue: null, + label: "Card quick actions enabled", + description: + "Show the selected favorite and watched shortcuts on media cards.", + category: "appearance", + control: "switch", + platforms: ["web"], + }, "ui.custom_css": { key: "ui.custom_css", type: "string", diff --git a/web/src/lib/uiCustomization.ts b/web/src/lib/uiCustomization.ts index dc83582f0..fe79c7fdc 100644 --- a/web/src/lib/uiCustomization.ts +++ b/web/src/lib/uiCustomization.ts @@ -314,6 +314,24 @@ export function carouselCardWidthClasses(size: PosterSize): string { } } +/** + * Placeholder height for an off-screen carousel row rendered under + * content-visibility: the row header plus a 2:3 poster at this size's card + * width plus the tallest caption. Kept beside carouselCardWidthClasses so a + * card-size change updates both; underestimating makes the scrollbar jump as + * rows render on approach. + */ +export function carouselIntrinsicHeight(size: PosterSize): string { + switch (size) { + case "compact": + return "21rem"; + case "large": + return "27rem"; + default: + return "23rem"; + } +} + export function cardTextAreaHeight(caption: CardCaption): number { if (caption === "artwork") return 0; return caption === "title" ? 28 : 44; diff --git a/web/src/pages/AdminAutoscan.tsx b/web/src/pages/AdminAutoscan.tsx index 721d1a3fb..e52c76ad8 100644 --- a/web/src/pages/AdminAutoscan.tsx +++ b/web/src/pages/AdminAutoscan.tsx @@ -16,31 +16,10 @@ import { import ConnectionsPanel from "@/pages/admin/autoscan/ConnectionsPanel"; import ActivityPanel from "@/pages/admin/autoscan/ActivityPanel"; import SourcesPanel from "@/pages/admin/autoscan/SourcesPanel"; - -// --------------------------------------------------------------------------- -// Tab routing helpers -// --------------------------------------------------------------------------- - -const AUTOSCAN_TABS = ["sources", "activity"] as const; -type AutoscanTab = (typeof AUTOSCAN_TABS)[number]; - -/** - * Connections and settings used to be peer tabs, which read as "set these up - * first" — most operators never needed either. They now live in an Advanced - * section on the Sources view, so their old deep links land on Sources with - * that section already open rather than 404-ing into a missing tab. - */ -const LEGACY_ADVANCED_TABS = new Set(["connections", "settings"]); - -function normalizeTab(value: string | null): AutoscanTab { - return AUTOSCAN_TABS.includes(value as AutoscanTab) - ? (value as AutoscanTab) - : "sources"; -} - -function isLegacyAdvancedTab(value: string | null): boolean { - return value !== null && LEGACY_ADVANCED_TABS.has(value); -} +import { + isLegacyAdvancedTab, + normalizeTab, +} from "@/pages/autoscanSearchParams"; // --------------------------------------------------------------------------- // Settings tab @@ -140,9 +119,21 @@ function SettingsTab() { // Page // --------------------------------------------------------------------------- -export default function AdminAutoscan() { +interface AdminAutoscanProps { + /** + * Rendered inside the Libraries page rather than as its own route. The + * heading drops to an h2 and the Sources/Activity selection moves to `view`, + * because `tab` already names the Libraries tab that hosts this panel. + */ + embedded?: boolean; +} + +export default function AdminAutoscan({ + embedded = false, +}: AdminAutoscanProps = {}) { const [searchParams, setSearchParams] = useSearchParams(); - const requestedTab = searchParams.get("tab"); + const tabParam = embedded ? "view" : "tab"; + const requestedTab = searchParams.get(tabParam); const activeTab = normalizeTab(requestedTab); const trigger = useTriggerAutoscan(); const settings = useAutoscanSettings(); @@ -164,9 +155,9 @@ export default function AdminAutoscan() { function setActiveTab(value: string) { const next = new URLSearchParams(searchParams); if (value === "sources") { - next.delete("tab"); + next.delete(tabParam); } else { - next.set("tab", value); + next.set(tabParam, value); } setSearchParams(next, { replace: true }); } @@ -176,7 +167,13 @@ export default function AdminAutoscan() {
-

Autoscan

+ {embedded ? ( +

Autoscan

+ ) : ( +

+ Autoscan +

+ )} {settings.data && (enabled ? ( Enabled @@ -187,7 +184,7 @@ export default function AdminAutoscan() { ))}

- Prairie re-scans a library as soon as something changes, instead of + Silo re-scans a library as soon as something changes, instead of waiting for the next scheduled scan. Add a source for each thing you want watched.

diff --git a/web/src/pages/AdminDashboard.tsx b/web/src/pages/AdminDashboard.tsx index 56a7ce8c4..eb5766508 100644 --- a/web/src/pages/AdminDashboard.tsx +++ b/web/src/pages/AdminDashboard.tsx @@ -2,15 +2,12 @@ import type { ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useNavigate } from "react-router"; import { AdminSessionActions } from "@/components/AdminSessionActions"; -import { AdminSectionCommandDialog } from "@/components/AdminSectionCommandDialog"; import { useEventChannel } from "@/components/realtimeEventsContext"; import { fetchAdminStats, useAdminStats, useAdminSessions, } from "@/hooks/queries/admin/stats"; -import { useAdminPluginInstallations } from "@/hooks/queries/admin/plugins"; -import { usePolicyCapability } from "@/hooks/queries/admin/policy"; import { useAdminUsers } from "@/hooks/queries/admin/users"; import { useAdminLibraries, @@ -56,7 +53,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { adminKeys } from "@/hooks/queries/keys"; import { usePageActivity } from "@/hooks/usePageActivity"; import { cn } from "@/lib/utils"; -import { buildAdminCommandNavSections } from "@/lib/adminNavigation"; import { compareActiveScans, formatActiveScanMode, @@ -102,8 +98,6 @@ export default function AdminDashboard() { const sessionsQuery = useAdminSessions(); const librariesQuery = useAdminLibraries(); const usersQuery = useAdminUsers(); - const { data: adminInstallations } = useAdminPluginInstallations(); - const policyCapability = usePolicyCapability(); const scanAll = useScanAllLibraries(); const pageActivity = usePageActivity(); const manualRefreshStartedAtRef = useRef(null); @@ -141,13 +135,6 @@ export default function AdminDashboard() { const lastUpdatedLabel = lastDashboardUpdatedAt ? formatRelativeUpdatedLabel(relativeUpdatedNow, lastDashboardUpdatedAt) : null; - const adminSearchSections = useMemo( - () => - buildAdminCommandNavSections(adminInstallations, { - policyEditorAvailable: policyCapability.data?.editor_available === true, - }), - [adminInstallations, policyCapability.data?.editor_available], - ); useEffect(() => { if (!lastDashboardUpdatedAt) { @@ -268,8 +255,6 @@ export default function AdminDashboard() { return (
- - {/* Page header */}
diff --git a/web/src/pages/AdminDevices.tsx b/web/src/pages/AdminDevices.tsx index 88422c3f5..c3f54fdde 100644 --- a/web/src/pages/AdminDevices.tsx +++ b/web/src/pages/AdminDevices.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useMemo, useState, type ReactNode } from "react"; import { Link, useParams, useSearchParams } from "react-router"; import { Activity, @@ -249,20 +249,6 @@ export default function AdminDevices() { const [groupBy, setGroupBy] = useState("user"); const [overridesOnly, setOverridesOnly] = useState(false); - // ⌘K focuses the global search input - const searchRef = useRef(null); - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { - e.preventDefault(); - searchRef.current?.focus(); - searchRef.current?.select(); - } - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, []); - // anomaly detection const anomalies = useMemo(() => detectAnomalies(devices), [devices]); const scopedDevices = useMemo( @@ -446,15 +432,14 @@ export default function AdminDevices() { ⌘K {" "} - to jump. + to jump to another admin page.

setSearch(event.target.value)} @@ -468,11 +453,7 @@ export default function AdminDevices() { > - ) : ( - - ⌘K - - )} + ) : null}
diff --git a/web/src/pages/AdminLibraries.tsx b/web/src/pages/AdminLibraries.tsx index 7642c4478..cb3e4cffa 100644 --- a/web/src/pages/AdminLibraries.tsx +++ b/web/src/pages/AdminLibraries.tsx @@ -75,7 +75,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Link } from "react-router"; +import { Link, useSearchParams } from "react-router"; import { Plus, Pencil, @@ -99,6 +99,8 @@ import { Search, FolderOpen, } from "lucide-react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import AdminAutoscan from "@/pages/AdminAutoscan"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -145,8 +147,33 @@ const EMPTY_ROOT_WARNING_TEXT = const EMPTY_ROOT_WARNING_HINT = "Run another scan after storage returns, or confirm deletion before the next empty-root scan."; +const LIBRARY_TABS = ["libraries", "autoscan"] as const; +type LibraryTab = (typeof LIBRARY_TABS)[number]; + export default function AdminLibraries() { useEventChannel("scans"); + // Autoscan used to be its own sidebar page even though it only ever + // configured how these libraries get scanned; it is a tab here now, and + // /admin/autoscan redirects to it. + const [searchParams, setSearchParams] = useSearchParams(); + const requestedTab = searchParams.get("tab"); + const activeTab: LibraryTab = LIBRARY_TABS.includes( + requestedTab as LibraryTab, + ) + ? (requestedTab as LibraryTab) + : "libraries"; + + function setActiveTab(value: string) { + const next = new URLSearchParams(searchParams); + if (value === "libraries") { + next.delete("tab"); + next.delete("view"); + } else { + next.set("tab", value); + } + setSearchParams(next, { replace: true }); + } + const { data: libraries = [], isLoading } = useAdminLibraries(); const { data: activeScans = [] } = useActiveScans(); const { data: libraryRefreshJobs = [] } = useLibraryRefreshJobs(); @@ -325,7 +352,8 @@ export default function AdminLibraries() { }); } - if (isLoading) return
Loading libraries...
; + if (isLoading && activeTab === "libraries") + return
Loading libraries...
; return (
@@ -366,7 +394,9 @@ export default function AdminLibraries() { under Maintenance.

-
+
{activeScanGroups.length > 0 && (
- -
- - - - - Name - Paths - Type - Status - Last Scanned - Actions - - - l.id)} - strategy={verticalListSortingStrategy} - > - - {orderedLibraries.map((lib) => { - const isScanning = - scanMutation.isPending && scanMutation.variables === lib.id; - const activeRefreshJob = activeRefreshJobsByLibraryId.get( - lib.id, - ); - const activeLibraryScans = - activeScansByLibraryId.get(lib.id) ?? []; - const runningLibraryScans = activeLibraryScans.filter( - (scan) => scan.status === "running", - ).length; - const queuedLibraryScans = - activeLibraryScans.length - runningLibraryScans; - const isRefreshStarting = - refreshMutation.isPending && - refreshMutation.variables === lib.id; - const isCheckingMount = - mountCheckMutation.isPending && - mountCheckMutation.variables === lib.id; - const mountCheck = lastMountCheckByLibraryId[lib.id]; - const hasActiveWork = - activeRefreshJob !== undefined || - activeLibraryScans.length > 0; - const isCancellingLibraryScans = - cancelScansMutation.isPending && - cancelScansMutation.variables === lib.id; - const isCancellingRefreshJob = - activeRefreshJob !== undefined && - cancelAdminJobMutation.isPending && - cancelAdminJobMutation.variables === activeRefreshJob.id; - return ( - - - - {lib.name} - - - {lib.paths.length === 1 ? ( - - {lib.paths[0]} - - ) : ( - - )} - - - {lib.type} - - -
- - {lib.enabled ? "Enabled" : "Disabled"} - - {runningLibraryScans > 0 ? ( - - {runningLibraryScans} running - - ) : null} - {queuedLibraryScans > 0 ? ( - - {queuedLibraryScans} queued - - ) : null} - {lib.scan_warning_code === "empty_root" ? ( - - Empty root guarded - - ) : null} - {lib.scan_warning_code === "dead_root" ? ( - - Root unreachable - - ) : null} -
-
- -
-
- {lib.last_scanned_at - ? formatDateTime(lib.last_scanned_at) - : "Never"} -
- {lib.scan_warning_at ? ( -
- Warning: {formatDateTime(lib.scan_warning_at)} -
- ) : null} -
-
- -
- -
+ + + + Name + Paths + Type + Status + Last Scanned + Actions + + + l.id)} + strategy={verticalListSortingStrategy} + > + + {orderedLibraries.map((lib) => { + const isScanning = + scanMutation.isPending && + scanMutation.variables === lib.id; + const activeRefreshJob = activeRefreshJobsByLibraryId.get( + lib.id, + ); + const activeLibraryScans = + activeScansByLibraryId.get(lib.id) ?? []; + const runningLibraryScans = activeLibraryScans.filter( + (scan) => scan.status === "running", + ).length; + const queuedLibraryScans = + activeLibraryScans.length - runningLibraryScans; + const isRefreshStarting = + refreshMutation.isPending && + refreshMutation.variables === lib.id; + const isCheckingMount = + mountCheckMutation.isPending && + mountCheckMutation.variables === lib.id; + const mountCheck = lastMountCheckByLibraryId[lib.id]; + const hasActiveWork = + activeRefreshJob !== undefined || + activeLibraryScans.length > 0; + const isCancellingLibraryScans = + cancelScansMutation.isPending && + cancelScansMutation.variables === lib.id; + const isCancellingRefreshJob = + activeRefreshJob !== undefined && + cancelAdminJobMutation.isPending && + cancelAdminJobMutation.variables === + activeRefreshJob.id; + return ( + + + + {lib.name} + + + {lib.paths.length === 1 ? ( + + {lib.paths[0]} + ) : ( - + )} - - - - - {lib.scan_warning_code === "empty_root" || - lib.scan_warning_code === "dead_root" ? ( - - ) : null} - - - - {hasActiveWork ? ( - - cancelAdminJobMutation.mutate(jobID) - } - onCancelScans={(libraryID) => - cancelScansMutation.mutate(libraryID) - } - /> - ) : null} - - ); - })} - {orderedLibraries - .filter( - (lib) => - lib.scan_warning_code === "empty_root" || - lib.scan_warning_code === "dead_root", - ) - .map((lib) => { - const mountCheck = lastMountCheckByLibraryId[lib.id]; - const isCheckingMount = - mountCheckMutation.isPending && - mountCheckMutation.variables === lib.id; - return ( - - -
-
- {lib.scan_warning_code === "dead_root" - ? DEAD_ROOT_WARNING_TEXT - : EMPTY_ROOT_WARNING_TEXT} -
-
- {lib.scan_warning_message ?? - (lib.scan_warning_code === "dead_root" - ? DEAD_ROOT_WARNING_HINT - : EMPTY_ROOT_WARNING_HINT)} -
-
- - {lib.scan_warning_code === "dead_root" ? ( + + + {lib.type} + + +
+ + {lib.enabled ? "Enabled" : "Disabled"} + + {runningLibraryScans > 0 ? ( + + {runningLibraryScans} running + + ) : null} + {queuedLibraryScans > 0 ? ( + + {queuedLibraryScans} queued + + ) : null} + {lib.scan_warning_code === "empty_root" ? ( + + Empty root guarded + + ) : null} + {lib.scan_warning_code === "dead_root" ? ( + + Root unreachable + + ) : null} +
+
+ +
+
+ {lib.last_scanned_at + ? formatDateTime(lib.last_scanned_at) + : "Never"} +
+ {lib.scan_warning_at ? ( +
+ Warning:{" "} + {formatDateTime(lib.scan_warning_at)} +
+ ) : null} +
+
+ +
+ - ) : null} -
-
- - - ); - })} - - -
-
- - {activeLibrary ? ( - - - - - - - - {activeLibrary.name} - - - {activeLibrary.paths.length === 1 - ? activeLibrary.paths[0] - : `${activeLibrary.paths.length} folders`} - - - {activeLibrary.type} - - - - - - -
+ + + + {lib.scan_warning_code === "empty_root" || + lib.scan_warning_code === "dead_root" ? ( + + ) : null} +
+ + + {hasActiveWork ? ( + + cancelAdminJobMutation.mutate(jobID) + } + onCancelScans={(libraryID) => + cancelScansMutation.mutate(libraryID) + } + /> + ) : null} + + ); + })} + {orderedLibraries + .filter( + (lib) => + lib.scan_warning_code === "empty_root" || + lib.scan_warning_code === "dead_root", + ) + .map((lib) => { + const mountCheck = lastMountCheckByLibraryId[lib.id]; + const isCheckingMount = + mountCheckMutation.isPending && + mountCheckMutation.variables === lib.id; + return ( + + +
+
+ {lib.scan_warning_code === "dead_root" + ? DEAD_ROOT_WARNING_TEXT + : EMPTY_ROOT_WARNING_TEXT} +
+
+ {lib.scan_warning_message ?? + (lib.scan_warning_code === "dead_root" + ? DEAD_ROOT_WARNING_HINT + : EMPTY_ROOT_WARNING_HINT)} +
+
+ + {lib.scan_warning_code === "dead_root" ? ( + + ) : null} +
+
+
+
+ ); + })} + + + +
+ + {activeLibrary ? ( + + + + + + + + {activeLibrary.name} + + + {activeLibrary.paths.length === 1 + ? activeLibrary.paths[0] + : `${activeLibrary.paths.length} folders`} + + + {activeLibrary.type} + + + + + + +
+ ) : null} +
+ + + + + + {skippedRoots.length > 0 ? ( + ) : null} - - - - - - - {skippedRoots.length > 0 ? ( - - ) : null} - {staleIDs.length > 0 && } + {staleIDs.length > 0 && } + + + + + +
); } diff --git a/web/src/pages/AdminPlugins.test.tsx b/web/src/pages/AdminPlugins.test.tsx index 1bf3789fb..eed74cdd8 100644 --- a/web/src/pages/AdminPlugins.test.tsx +++ b/web/src/pages/AdminPlugins.test.tsx @@ -1,3 +1,4 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter } from "react-router"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -143,6 +144,7 @@ vi.mock("@/hooks/queries/admin/plugins", () => ({ useApplyPluginUpdate: () => ({ mutate: vi.fn(), isPending: false }), useDeletePluginInstallation: () => ({ mutate: vi.fn(), isPending: false }), useSavePluginConfig: () => ({ mutate: vi.fn(), isPending: false }), + useTestPluginConfig: () => ({ mutate: vi.fn(), isPending: false }), useSavePluginAuthBinding: () => ({ mutate: vi.fn(), isPending: false }), useSavePluginTaskBinding: () => ({ mutate: vi.fn(), isPending: false }), })); @@ -500,4 +502,61 @@ describe("AdminPlugins", () => { expect(markup).toContain(">13– { + const installation = makeInstallation(1, "TheIntroDB"); + useAdminPluginsMock.mockReturnValue({ + repositories: [], + catalog: [], + installations: [installation], + catalogSettings: undefined, + isLoading: false, + }); + + render( + + + , + ); + + const dialog = await screen.findByRole("dialog"); + expect( + within(dialog).getByText( + "Configure bindings, credentials, and runtime settings.", + ), + ).toBeInTheDocument(); + expect( + within(dialog).getByText(installation.plugin_id), + ).toBeInTheDocument(); + }); + + it("ignores a ?configure deep link for a plugin that is not installed", async () => { + useAdminPluginsMock.mockReturnValue({ + repositories: [], + catalog: [], + installations: [makeInstallation(1, "TheIntroDB")], + catalogSettings: undefined, + isLoading: false, + }); + + render( + + + , + ); + + await waitFor(() => + expect( + screen.queryByText( + "Configure bindings, credentials, and runtime settings.", + ), + ).not.toBeInTheDocument(), + ); + }); }); diff --git a/web/src/pages/AdminPlugins.tsx b/web/src/pages/AdminPlugins.tsx index 1a64c655c..e922f297b 100644 --- a/web/src/pages/AdminPlugins.tsx +++ b/web/src/pages/AdminPlugins.tsx @@ -100,9 +100,8 @@ function capabilityLabel(type: string): string { function sourceLabel(sourceKind: string): string { switch (sourceKind) { - case "prairie": case "silo": - return "Prairie maintained"; + return "Silo maintained"; case "approved_community": return "Approved community"; default: @@ -118,7 +117,7 @@ function pluginDisplayName( if (displayName) return displayName; return pluginID - .replace(/^(?:prairie|silo)[._-]?/, "") + .replace(/^silo[._-]?/, "") .split(/[._-]+/) .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) @@ -493,7 +492,7 @@ function InstalledPluginCard({ {pluginDisplayName(installation.plugin_id, presentation)}? - Prairie will stop the plugin, then remove its installation, + Silo will stop the plugin, then remove its installation, configuration, and installed files. This cannot be undone. @@ -903,7 +902,7 @@ function CommunityCatalogControl({

- Reviewed by Prairie maintainers to work as described and be safe for + Reviewed by Silo maintainers to work as described and be safe for their documented use. These plugins remain maintained and supported by community contributors.

@@ -1022,7 +1021,6 @@ function RepositorySection() { className="sm:flex-[2]" /> @@ -1051,7 +1049,7 @@ function RepositorySection() {
{repo.managed ? ( - Managed by Prairie + Managed by Silo ) : ( <> @@ -1153,9 +1151,6 @@ export default function AdminPlugins() { const queryClient = useQueryClient(); const checkPluginUpdates = useCheckPluginUpdates(); const { data: pluginUpdateTask } = useTask(CHECK_PLUGIN_UPDATES_TASK_KEY); - const [configuring, setConfiguring] = useState( - null, - ); const previousTaskState = useRef(null); const installedIds = useMemo( @@ -1247,6 +1242,18 @@ export default function AdminPlugins() { setSearchParams(next, { replace: options.replace ?? true }); } + // The configure dialog is URL state: ?configure=. Provider tiles + // on the settings pages deep-link straight into a plugin's credential dialog + // this way, and closing the dialog (or browser back) just drops the param. + // An id that matches no installation renders nothing. + const configuring = useMemo( + () => + installations.find( + (candidate) => candidate.plugin_id === searchParams.get("configure"), + ) ?? null, + [installations, searchParams], + ); + useEffect(() => { const currentState = pluginUpdateTask?.state ?? null; const previousState = previousTaskState.current; @@ -1256,13 +1263,11 @@ export default function AdminPlugins() { (previousState === "running" || previousState === "cancelling") && currentState === "idle" ) { - void queryClient.invalidateQueries({ + queryClient.invalidateQueries({ queryKey: adminKeys.pluginRepositories(), }); - void queryClient.invalidateQueries({ - queryKey: adminKeys.pluginCatalog(), - }); - void queryClient.invalidateQueries({ + queryClient.invalidateQueries({ queryKey: adminKeys.pluginCatalog() }); + queryClient.invalidateQueries({ queryKey: adminKeys.pluginInstallations(), }); } @@ -1276,7 +1281,7 @@ export default function AdminPlugins() {

Plugins

- Extend Prairie with community and first-party plugins. + Extend Silo with community and first-party plugins.

@@ -1292,7 +1297,7 @@ export default function AdminPlugins() {

Plugins

- Extend Prairie with community and first-party plugins. + Extend Silo with community and first-party plugins.

@@ -1492,7 +1503,7 @@ export default function AdminPlugins() { {configuring && ( setConfiguring(null)} + onClose={() => updatePluginView({ configure: undefined })} /> )}
diff --git a/web/src/pages/AdminRecommendations.tsx b/web/src/pages/AdminRecommendations.tsx index 37bbe4959..c81380dbf 100644 --- a/web/src/pages/AdminRecommendations.tsx +++ b/web/src/pages/AdminRecommendations.tsx @@ -667,7 +667,8 @@ export default function AdminRecommendations() { /> ))} {section.title === EMBEDDING_SECTION_TITLE ? ( -
+ // ConnectionCheckAction brings its own row padding. +
handleCheckConnection(serverSettings)} result={connectionResult} diff --git a/web/src/pages/AdminUsers.test.tsx b/web/src/pages/AdminUsers.test.tsx new file mode 100644 index 000000000..2317e8dcf --- /dev/null +++ b/web/src/pages/AdminUsers.test.tsx @@ -0,0 +1,173 @@ +// @vitest-environment jsdom + +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import AdminUsers from "./AdminUsers"; + +// The page is exercised for its tab wiring only; the user table's data and the +// two invite tabs each own their own queries and tests. +vi.mock("@/hooks/queries/admin/users", () => ({ + useAdminUsers: () => ({ data: [], isLoading: false }), + useCreateUser: () => ({ mutate: vi.fn(), isPending: false }), + useUpdateUser: () => ({ mutate: vi.fn(), isPending: false }), + useDeleteUser: () => ({ mutate: vi.fn(), isPending: false }), +})); + +const mocks = vi.hoisted(() => ({ + useAdminServerSettings: vi.fn(), +})); + +vi.mock("@/hooks/queries/admin/settings", () => ({ + useAdminServerSettings: (...args: unknown[]) => + mocks.useAdminServerSettings(...args), +})); + +vi.mock("@/hooks/queries/admin/libraries", () => ({ + useAdminLibraries: () => ({ data: [] }), +})); + +vi.mock("@/hooks/queries/admin/accessGroups", () => ({ + useAccessGroups: () => ({ data: [] }), +})); + +vi.mock("./admin-settings/InvitationsTab", () => ({ + default: () =>
Invitations panel
, +})); + +vi.mock("./admin-settings/InviteCodesTab", () => ({ + default: () =>
Invite codes panel
, +})); + +function LocationProbe() { + const location = useLocation(); + return ( + {`${location.pathname}${location.search}`} + ); +} + +function renderPage(entry = "/admin/users") { + return render( + + + + + + + } + /> + + , + ); +} + +function tab(name: string) { + return screen.getByRole("tab", { name }); +} + +describe("AdminUsers tabs", () => { + beforeEach(() => { + mocks.useAdminServerSettings.mockReset(); + mocks.useAdminServerSettings.mockReturnValue({ + data: { "signup.enabled": "false" }, + isLoading: false, + }); + }); + + it("opens on the users tab when no tab is requested", () => { + renderPage(); + + expect(tab("Users")).toHaveAttribute("aria-selected", "true"); + expect(tab("Invite Codes")).toHaveAttribute("aria-selected", "false"); + }); + + it("selects the Invite Codes tab from ?tab=invite-codes", () => { + renderPage("/admin/users?tab=invite-codes"); + + expect(tab("Invite Codes")).toHaveAttribute("aria-selected", "true"); + expect(screen.getByText("Invite codes panel")).toBeInTheDocument(); + }); + + it("falls back to the users tab for an unknown tab id", () => { + renderPage("/admin/users?tab=not-a-tab"); + + expect(tab("Users")).toHaveAttribute("aria-selected", "true"); + }); + + it("writes the selected tab to the URL and drops the param on the default tab", async () => { + renderPage(); + + await userEvent.click(tab("Invitations")); + expect(screen.getByTestId("location")).toHaveTextContent( + "/admin/users?tab=invitations", + ); + + await userEvent.click(tab("Users")); + expect(screen.getByTestId("location")).toHaveTextContent("/admin/users"); + expect(screen.getByTestId("location")).not.toHaveTextContent("tab="); + }); +}); + +describe("AdminUsers public-signup status badge", () => { + beforeEach(() => { + mocks.useAdminServerSettings.mockReset(); + }); + + it("shows a neutral 'off' badge linking to General settings when signups are disabled", () => { + mocks.useAdminServerSettings.mockReturnValue({ + data: { "signup.enabled": "false" }, + isLoading: false, + }); + renderPage(); + + const badge = screen.getByText("Public signups off"); + expect(badge).toHaveAttribute("data-variant", "secondary"); + const link = badge.closest("a"); + expect(link).toHaveAttribute("href", "/admin/settings/general"); + }); + + it("shows a positive 'on' badge linking to General settings when signups are enabled", () => { + mocks.useAdminServerSettings.mockReturnValue({ + data: { "signup.enabled": "true" }, + isLoading: false, + }); + renderPage(); + + const badge = screen.getByText("Public signups on"); + expect(badge).toHaveAttribute("data-variant", "outline"); + const link = badge.closest("a"); + expect(link).toHaveAttribute("href", "/admin/settings/general"); + }); + + it("renders no signup-status badge while settings are still loading", () => { + mocks.useAdminServerSettings.mockReturnValue({ + data: undefined, + isLoading: true, + }); + renderPage(); + + expect(screen.queryByText("Public signups on")).not.toBeInTheDocument(); + expect(screen.queryByText("Public signups off")).not.toBeInTheDocument(); + }); + + it("stays visible regardless of which tab is active", async () => { + mocks.useAdminServerSettings.mockReturnValue({ + data: { "signup.enabled": "true" }, + isLoading: false, + }); + renderPage(); + + expect(screen.getByText("Public signups on")).toBeInTheDocument(); + + await userEvent.click(tab("Invite Codes")); + expect(screen.getByText("Public signups on")).toBeInTheDocument(); + + await userEvent.click(tab("Invitations")); + expect(screen.getByText("Public signups on")).toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/AdminUsers.tsx b/web/src/pages/AdminUsers.tsx index 931f55b96..2e4dec289 100644 --- a/web/src/pages/AdminUsers.tsx +++ b/web/src/pages/AdminUsers.tsx @@ -1,6 +1,6 @@ import { useState, useId, useMemo } from "react"; import type { FormEvent, ReactNode } from "react"; -import { Link } from "react-router"; +import { Link, useSearchParams } from "react-router"; import type { AdminUser, CreateUserRequest, @@ -12,6 +12,7 @@ import { useUpdateUser, useDeleteUser, } from "@/hooks/queries/admin/users"; +import { useAdminServerSettings } from "@/hooks/queries/admin/settings"; import { useAdminLibraries } from "@/hooks/queries/admin/libraries"; import { useAccessGroups } from "@/hooks/queries/admin/accessGroups"; import { @@ -52,18 +53,15 @@ import { import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { + ArrowRight, ChevronDown, - ChevronLeft, - ChevronRight, ChevronUp, History, - Loader2, - Pencil, Plus, - Save, - Search, - Settings2, + Pencil, Trash2, + Settings2, + Search, X, } from "lucide-react"; import { ConfirmDialog } from "@/components/ConfirmDialog"; @@ -83,8 +81,23 @@ type UserSortField = "username" | "email" | "role" | "enabled" | "created_at" | "last_active_at"; type SortDirection = "asc" | "desc"; +// Tab ids are a URL contract: other pages deep-link here (General settings +// points at ?tab=invite-codes), so reuse the trigger values verbatim. +const ADMIN_USERS_TABS = ["users", "invitations", "invite-codes"] as const; +type AdminUsersTab = (typeof ADMIN_USERS_TABS)[number]; + +function normalizeAdminUsersTab(value: string | null): AdminUsersTab { + return ADMIN_USERS_TABS.includes(value as AdminUsersTab) + ? (value as AdminUsersTab) + : "users"; +} + export default function AdminUsers() { const { data: users = [], isLoading } = useAdminUsers(); + const { data: serverSettings } = useAdminServerSettings(); + const signupsEnabled = serverSettings?.["signup.enabled"] === "true"; + const [searchParams, setSearchParams] = useSearchParams(); + const activeTab = normalizeAdminUsersTab(searchParams.get("tab")); const [dialogOpen, setDialogOpen] = useState(false); const [editingUser, setEditingUser] = useState(null); const [confirmDeleteUser, setConfirmDeleteUser] = useState( @@ -134,6 +147,20 @@ export default function AdminUsers() { setConfirmDeleteUser(u); } + function setActiveTab(value: string) { + const nextTab = normalizeAdminUsersTab(value); + const next = new URLSearchParams(searchParams); + + // The default tab stays the bare /admin/users URL. + if (nextTab === "users") { + next.delete("tab"); + } else { + next.set("tab", nextTab); + } + + setSearchParams(next, { replace: true }); + } + if (isLoading) return (
@@ -164,9 +191,26 @@ export default function AdminUsers() {

Users

- Manage access, defaults, and invite flow for the people using - Prairie. + Manage access, defaults, and invite flow for the people using Silo.

+ {serverSettings !== undefined && ( + + + {signupsEnabled ? "Public signups on" : "Public signups off"} + + + )}
- + setPage((p) => p - 1)} disabled={page === 0} > - Previous
@@ -832,7 +874,6 @@ function UserForm({
diff --git a/web/src/pages/Catalog.test.tsx b/web/src/pages/Catalog.test.tsx index 6a96e95a1..12532b06e 100644 --- a/web/src/pages/Catalog.test.tsx +++ b/web/src/pages/Catalog.test.tsx @@ -19,11 +19,14 @@ vi.mock("react-router", async () => { return { ...actual, - BrowserRouter: ({ children }: { children: ReactNode }) => ( - - {children} - - ), + // App builds a data router from the real history; point it at the entry + // under test instead. + createBrowserRouter: (( + routes: Parameters[0], + ) => + actual.createMemoryRouter(routes, { + initialEntries: appInitialEntries, + })) as typeof actual.createBrowserRouter, Navigate: ({ to, replace, diff --git a/web/src/pages/Home.test.tsx b/web/src/pages/Home.test.tsx index dd86965d0..d6ad6d14b 100644 --- a/web/src/pages/Home.test.tsx +++ b/web/src/pages/Home.test.tsx @@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import Home from "./Home"; +import { sectionKeys } from "@/hooks/queries/keys"; ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -19,20 +20,14 @@ vi.mock("@/hooks/queries/sections", () => ({ useHomeLayout: (...args: unknown[]) => mockUseHomeLayout(...args), fetchHomeSectionItems: (...args: unknown[]) => mockFetchHomeSectionItems(...args), + HOME_SECTION_STALE_TIME: 10 * 60 * 1000, + HOME_SECTION_GC_TIME: 60 * 60 * 1000, })); vi.mock("@/hooks/useDocumentTitle", () => ({ useDocumentTitle: vi.fn(), })); -vi.mock("@/hooks/useServerBranding", () => ({ - useServerBranding: () => ({ serverName: "Prairie", loginSubtitle: null }), -})); - -vi.mock("@/components/PrairieBrand", () => ({ - PrairieBrand: () => , -})); - vi.mock("react-router", () => ({ Link: ({ children }: { children: ReactNode }) =>
{children}, })); @@ -41,10 +36,6 @@ vi.mock("@/components/TasteSeedBanner", () => ({ default: () =>
, })); -vi.mock("@/components/livetv/LiveTVOnNowRow", () => ({ - default: () => null, -})); - vi.mock("@/components/HeroBanner", () => ({ default: () =>
, })); @@ -53,24 +44,6 @@ vi.mock("@/components/SectionRow", () => ({ default: () =>
, })); -function layoutSection(overrides: { - id: string; - title?: string; - featured?: boolean; - item_limit?: number; -}) { - return { - id: overrides.id, - title: overrides.title ?? overrides.id, - section_type: "recently_added", - featured: overrides.featured ?? false, - item_limit: overrides.item_limit ?? 16, - is_custom: false, - customized: false, - position: 0, - }; -} - describe("Home", () => { let container: HTMLDivElement; let root: Root; @@ -80,13 +53,13 @@ describe("Home", () => { document.body.appendChild(container); root = createRoot(container); - mockFetchHomeSectionItems.mockReset(); mockUseHomeLayout.mockReturnValue({ data: { sections: [] }, isLoading: false, isError: false, refetch: vi.fn(), }); + mockFetchHomeSectionItems.mockReset(); }); afterEach(async () => { @@ -96,7 +69,13 @@ describe("Home", () => { container.remove(); }); - async function renderHome(queryClient = new QueryClient()) { + it("does not invalidate cached home sections on mount", async () => { + const invalidateQueries = vi.spyOn( + QueryClient.prototype, + "invalidateQueries", + ); + const queryClient = new QueryClient(); + await act(async () => { root.render( @@ -105,52 +84,52 @@ describe("Home", () => { ); await Promise.resolve(); }); - return queryClient; - } - it("does not invalidate cached home sections on mount", async () => { - const invalidateQueries = vi.spyOn( - QueryClient.prototype, - "invalidateQueries", - ); - await renderHome(); expect(invalidateQueries).not.toHaveBeenCalled(); invalidateQueries.mockRestore(); }); - it("shows a compact brand welcome only when home has no sections", async () => { - await renderHome(); - expect(container.querySelector('[aria-label="Welcome"]')).toBeTruthy(); - expect(container.querySelector('[data-kind="hero"]')).toBeNull(); - }); - - it("opens on carousel rows when sections exist without a featured hero", async () => { - mockFetchHomeSectionItems.mockResolvedValue({ - section: { - id: "recent", - title: "Recently Added", - section_type: "recently_added", - featured: false, - items: [{ content_id: "m1", title: "Movie", type: "movie" }], - total_count: 1, - }, - }); + it("keeps section items cached past the client-wide gc time", async () => { mockUseHomeLayout.mockReturnValue({ data: { - sections: [layoutSection({ id: "recent", title: "Recently Added" })], + sections: [ + { + id: "recent", + section_type: "recently_added", + title: "Recently Added", + featured: false, + item_limit: 10, + is_custom: false, + customized: false, + }, + ], }, isLoading: false, isError: false, refetch: vi.fn(), }); + mockFetchHomeSectionItems.mockResolvedValue({ + section: { id: "recent", items: [], total_count: 0 }, + }); + // The client default is what evicts observer-less section entries today. + const queryClient = new QueryClient({ + defaultOptions: { queries: { gcTime: 10 * 60_000 } }, + }); - await renderHome(); await act(async () => { - await Promise.resolve(); + root.render( + + + , + ); await Promise.resolve(); }); - expect(container.querySelector('[aria-label="Welcome"]')).toBeNull(); - expect(container.querySelector('[data-kind="hero"]')).toBeNull(); + const sectionQuery = queryClient + .getQueryCache() + .find({ queryKey: sectionKeys.homeItems("recent") }); + + expect(sectionQuery).toBeDefined(); + expect(sectionQuery?.gcTime).toBe(60 * 60 * 1000); }); }); diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 2b274bde8..2123423dc 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -13,7 +13,12 @@ import { HERO_BANNER_SIZE, HOME_BRAND_HERO_SIZE } from "@/lib/design-system"; import { PrairieBrand } from "@/components/PrairieBrand"; import { useServerBranding } from "@/hooks/useServerBranding"; import { sectionKeys } from "@/hooks/queries/keys"; -import { fetchHomeSectionItems, useHomeLayout } from "@/hooks/queries/sections"; +import { + fetchHomeSectionItems, + HOME_SECTION_GC_TIME, + HOME_SECTION_STALE_TIME, + useHomeLayout, +} from "@/hooks/queries/sections"; import { planNextHomeSectionBatch } from "./homeSectionQueue"; import { buildHomeSectionViewModel, @@ -22,7 +27,6 @@ import { import { collectCachedHomeSections } from "./homeSectionCache"; import { useSectionRefreshSignal } from "./homeSurfaceRefresh"; -const SECTION_STALE_TIME = 5 * 60 * 1000; const MAX_CONCURRENT_SECTION_REQUESTS = 5; const SKELETON_CARD_COUNT = 7; @@ -95,7 +99,8 @@ export default function Home() { .fetchQuery({ queryKey: sectionKeys.homeItems(sectionId), queryFn: ({ signal }) => fetchHomeSectionItems(sectionId, { signal }), - staleTime: SECTION_STALE_TIME, + staleTime: HOME_SECTION_STALE_TIME, + gcTime: HOME_SECTION_GC_TIME, }) .then((response) => { if (!activeSectionIdsRef.current.has(sectionId)) return; diff --git a/web/src/pages/ItemDetail/components/EpisodeCarousel.test.tsx b/web/src/pages/ItemDetail/components/EpisodeCarousel.test.tsx index f2f498e50..155f0a807 100644 --- a/web/src/pages/ItemDetail/components/EpisodeCarousel.test.tsx +++ b/web/src/pages/ItemDetail/components/EpisodeCarousel.test.tsx @@ -12,6 +12,10 @@ vi.mock("@/hooks/queries/catalogRead", () => ({ usePrefetchCatalogItemDetail: () => prefetchEpisodeDetail, })); +vi.mock("@/hooks/useOverlayPrefs", () => ({ + useOverlayPrefs: () => ({ quickActionMode: "watched" }), +})); + vi.mock("@/components/MediaItemMenu", () => ({ default: (props: Record) => { capturedMenuProps.push(props); @@ -211,6 +215,7 @@ describe("EpisodeCarousel", () => { showCollectionActions: false, showWatchedShortcut: true, hasPartialProgress: false, + quickActionMode: "watched", }); expect(capturedMenuProps[1]).toMatchObject({ contentId: "ep-2", @@ -221,6 +226,7 @@ describe("EpisodeCarousel", () => { }, showWatchedShortcut: true, hasPartialProgress: true, + quickActionMode: "watched", }); }); }); diff --git a/web/src/pages/ItemDetail/components/EpisodeCarousel.tsx b/web/src/pages/ItemDetail/components/EpisodeCarousel.tsx index ea42dcbd9..ed0c6a68d 100644 --- a/web/src/pages/ItemDetail/components/EpisodeCarousel.tsx +++ b/web/src/pages/ItemDetail/components/EpisodeCarousel.tsx @@ -10,6 +10,8 @@ import MediaItemMenu from "@/components/MediaItemMenu"; import type { EpisodeNavigationState } from "../itemDetailLayout"; import { useCarouselEmbla } from "@/hooks/useCarouselEmbla"; import { usePrefetchCatalogItemDetail } from "@/hooks/queries/catalogRead"; +import { useOverlayPrefs } from "@/hooks/useOverlayPrefs"; +import type { CardQuickActionMode } from "@/lib/cardQuickActions"; interface EpisodeCarouselProps { episodes: EpisodeListItem[]; @@ -26,6 +28,7 @@ export default function EpisodeCarousel({ (episode) => episode.episode_number === currentEpisodeNumber, ); const prefetchEpisodeDetail = usePrefetchCatalogItemDetail(); + const { quickActionMode } = useOverlayPrefs(); const { emblaApi, emblaRef, @@ -73,6 +76,7 @@ export default function EpisodeCarousel({ ep={ep} isCurrent={ep.episode_number === currentEpisodeNumber} episodeLinkState={episodeLinkState} + quickActionMode={quickActionMode} onPrefetch={() => prefetchEpisodeDetail(ep.content_id)} /> ))} @@ -98,11 +102,13 @@ function EpisodeCarouselCard({ ep, isCurrent, episodeLinkState, + quickActionMode, onPrefetch, }: { ep: EpisodeListItem; isCurrent: boolean; episodeLinkState?: EpisodeNavigationState; + quickActionMode: CardQuickActionMode; onPrefetch: () => void; }) { const cardRef = useRef(null); @@ -199,6 +205,7 @@ function EpisodeCarouselCard({ showCollectionActions={false} showWatchedShortcut hasPartialProgress={progress != null} + quickActionMode={quickActionMode} longPressRef={cardRef} itemTitle={episodeTitle} /> diff --git a/web/src/pages/ItemDetail/components/SeasonEpisodeGrid.test.tsx b/web/src/pages/ItemDetail/components/SeasonEpisodeGrid.test.tsx index eaa6ccec2..83675dfa2 100644 --- a/web/src/pages/ItemDetail/components/SeasonEpisodeGrid.test.tsx +++ b/web/src/pages/ItemDetail/components/SeasonEpisodeGrid.test.tsx @@ -13,7 +13,7 @@ vi.mock("@/components/MediaItemMenu", () => ({ })); vi.mock("@/hooks/useOverlayPrefs", () => ({ - useOverlayPrefs: () => ({ prefs: null }), + useOverlayPrefs: () => ({ prefs: null, quickActionMode: "watched" }), })); vi.mock("@/hooks/queries/catalogRead", () => ({ @@ -59,6 +59,7 @@ describe("SeasonEpisodeGrid", () => { showCollectionActions: false, showWatchedShortcut: true, hasPartialProgress: false, + quickActionMode: "watched", }); }); diff --git a/web/src/pages/ItemDetail/components/SeasonEpisodeGrid.tsx b/web/src/pages/ItemDetail/components/SeasonEpisodeGrid.tsx index 0453e310f..01d2fa751 100644 --- a/web/src/pages/ItemDetail/components/SeasonEpisodeGrid.tsx +++ b/web/src/pages/ItemDetail/components/SeasonEpisodeGrid.tsx @@ -8,6 +8,7 @@ import MediaItemMenu from "@/components/MediaItemMenu"; import CardOverlays from "@/components/overlays/CardOverlays"; import { useOverlayPrefs } from "@/hooks/useOverlayPrefs"; import { usePrefetchCatalogItemDetail } from "@/hooks/queries/catalogRead"; +import type { CardQuickActionMode } from "@/lib/cardQuickActions"; import { overlayDataFromEpisodeListItem, type CardOverlayPrefs, @@ -26,7 +27,7 @@ export default function SeasonEpisodeGrid({ isLoading, episodeLinkState, }: SeasonEpisodeGridProps) { - const { prefs: overlayPrefs } = useOverlayPrefs(); + const { prefs: overlayPrefs, quickActionMode } = useOverlayPrefs(); const prefetchEpisodeDetail = usePrefetchCatalogItemDetail(); if (isLoading) { @@ -49,6 +50,7 @@ export default function SeasonEpisodeGrid({ episode={episode} episodeLinkState={episodeLinkState} overlayPrefs={overlayPrefs} + quickActionMode={quickActionMode} onPrefetch={() => prefetchEpisodeDetail(episode.content_id)} /> ))} @@ -60,11 +62,13 @@ function SeasonEpisodeCard({ episode, episodeLinkState, overlayPrefs, + quickActionMode, onPrefetch, }: { episode: EpisodeListItem; episodeLinkState?: EpisodeNavigationState; overlayPrefs: CardOverlayPrefs | null; + quickActionMode: CardQuickActionMode; onPrefetch: () => void; }) { const cardRef = useRef(null); @@ -137,6 +141,7 @@ function SeasonEpisodeCard({ showCollectionActions={false} showWatchedShortcut hasPartialProgress={hasPartialProgress} + quickActionMode={quickActionMode} longPressRef={cardRef} itemTitle={episodeTitle} /> diff --git a/web/src/pages/LibraryPage.test.tsx b/web/src/pages/LibraryPage.test.tsx index 7f21bec4b..bf20490e3 100644 --- a/web/src/pages/LibraryPage.test.tsx +++ b/web/src/pages/LibraryPage.test.tsx @@ -15,6 +15,8 @@ const mocks = vi.hoisted(() => ({ savedSearch: "tab=library" as string | undefined, saveLibrarySearch: vi.fn<(libraryId: number, search: string) => Promise>(), + renderOnlyActiveTab: false, + recommendedMounts: 0, })); vi.mock("@/hooks/queries/libraries", () => ({ @@ -68,22 +70,51 @@ vi.mock("@/components/LibraryHeader", () => ({ default: () =>
Library header
, })); -vi.mock("@/components/ui/tabs", () => ({ - Tabs: ({ children }: { children: ReactNode }) =>
{children}
, - TabsContent: ({ children }: { children: ReactNode }) =>
{children}
, -})); +vi.mock("@/components/ui/tabs", async () => { + const { createContext, useContext } = await import("react"); + const ActiveTabContext = createContext(undefined); + return { + Tabs: ({ value, children }: { value: string; children: ReactNode }) => ( + + {children} + + ), + TabsContent: ({ + value, + children, + }: { + value: string; + children: ReactNode; + }) => { + const activeTab = useContext(ActiveTabContext); + // Radix only mounts the active panel. Most tests here need to reach into + // several panels at once, so that behavior is opt-in per test. + if (mocks.renderOnlyActiveTab && activeTab !== value) { + return null; + } + return
{children}
; + }, + }; +}); -vi.mock("./LibraryRecommended", () => ({ - default: ({ +vi.mock("./LibraryRecommended", async () => { + const { useEffect } = await import("react"); + function LibraryRecommendedMock({ onHeroStateChange, }: { onHeroStateChange: (rendered: boolean) => void; - }) => ( - - ), -})); + }) { + useEffect(() => { + mocks.recommendedMounts += 1; + }, []); + return ( + + ); + } + return { default: LibraryRecommendedMock }; +}); vi.mock("./LibraryBrowse", () => ({ default: () =>
Library browse
, @@ -119,10 +150,29 @@ describe("LibraryPage saved state", () => { mocks.ownerKey = "profile-1"; mocks.rememberEnabled = true; mocks.savedSearch = "tab=library"; + mocks.renderOnlyActiveTab = false; + mocks.recommendedMounts = 0; mocks.saveLibrarySearch.mockReset(); mocks.saveLibrarySearch.mockResolvedValue(); }); + it("never mounts the Recommended tab when saved state lands on the Library tab", async () => { + mocks.renderOnlyActiveTab = true; + mocks.savedSearch = "tab=library&sort=year&order=desc"; + + renderPage("/libraries/7"); + + await waitFor(() => + expect(screen.getByTestId("location-search")).toHaveTextContent( + "?tab=library&sort=year&order=desc", + ), + ); + expect(screen.getByText("Library browse")).toBeInTheDocument(); + // The hydration effect must not drop the skeleton before the rewritten URL + // lands, or Recommended mounts for a frame and fires its section queries. + expect(mocks.recommendedMounts).toBe(0); + }); + it("submits one save while the cached value remains stale across unrelated rerenders", async () => { renderPage(); diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index 9c4d66303..883f47b55 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + startTransition, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Link, useParams, useSearchParams } from "react-router"; import type { QueryDefinition } from "@/api/types"; import { Tabs, TabsContent } from "@/components/ui/tabs"; @@ -151,22 +158,38 @@ export default function LibraryPage() { return; } - setSavedStateHydratedKey(libraryPageStateKey); const nextSearchParams = applySavedLibraryPageSearchParams( searchParams, rememberLibraryPageState ? (savedLibrarySearch ?? "") : "", ); const hydratedSearch = serializeLibraryPageSearchParams(nextSearchParams); - setHydratedLibrarySearch({ + const nextHydratedLibrarySearch: HydratedLibrarySearch = { ownerKey: libraryPageStateOwnerKey, libraryId: id, search: hydratedSearch, - }); - if (nextSearchParams.toString() !== searchParams.toString()) { - applyingSavedSearchParamsRef.current = hydratedSearch; - applyingSavedSearchParamsKeyRef.current = libraryPageStateKey; - setSearchParams(nextSearchParams, { replace: true }); + }; + + if (nextSearchParams.toString() === searchParams.toString()) { + // The saved search already matches the URL, so there is nothing to + // navigate to and the page can leave the skeleton immediately. + setSavedStateHydratedKey(libraryPageStateKey); + setHydratedLibrarySearch(nextHydratedLibrarySearch); + return; } + + applyingSavedSearchParamsRef.current = hydratedSearch; + applyingSavedSearchParamsKeyRef.current = libraryPageStateKey; + // react-router wraps setSearchParams in a transition. Marking hydration as + // done urgently would commit first, dropping the skeleton while + // `searchParams` still read the pre-navigation URL — long enough for the + // default Recommended tab to mount and fire its section queries before the + // transition lands the saved tab. Keeping all three updates in one + // transition commits the marker and the URL together. + startTransition(() => { + setSavedStateHydratedKey(libraryPageStateKey); + setHydratedLibrarySearch(nextHydratedLibrarySearch); + setSearchParams(nextSearchParams, { replace: true }); + }); }, [ id, libraryPageStateKey, diff --git a/web/src/pages/LibraryRecommended.tsx b/web/src/pages/LibraryRecommended.tsx index 45409e53c..fca8c4bca 100644 --- a/web/src/pages/LibraryRecommended.tsx +++ b/web/src/pages/LibraryRecommended.tsx @@ -365,7 +365,7 @@ function PinnedCollectionCarousel({ libraryId, collectionId, ); - const { prefs: overlayPrefs } = useOverlayPrefs(); + const { prefs: overlayPrefs, quickActionMode } = useOverlayPrefs(); const { cardPresentation } = useUICustomization(); const posterWidthClasses = carouselCardWidthClasses( cardPresentation.poster_size, @@ -377,7 +377,11 @@ function PinnedCollectionCarousel({ {(items ?? []).map((item) => (
- +
))}
diff --git a/web/src/pages/Recommendations.tsx b/web/src/pages/Recommendations.tsx index 4a6c195eb..d2cb8fb0a 100644 --- a/web/src/pages/Recommendations.tsx +++ b/web/src/pages/Recommendations.tsx @@ -3,6 +3,7 @@ import type { DiscoverRow } from "@/api/types"; import MediaCarousel from "@/components/MediaCarousel"; import SectionItemCard from "@/components/SectionItemCard"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; +import { useOverlayPrefs } from "@/hooks/useOverlayPrefs"; import { Skeleton } from "@/components/ui/skeleton"; import { Sparkles, RefreshCw } from "lucide-react"; import { useUICustomization } from "@/hooks/useUICustomization"; @@ -163,6 +164,7 @@ export default function Recommendations() { const tasteProfileQuery = useTasteProfile(); const { data, isLoading, isError, refetch } = useDiscover(); + const { prefs: overlayPrefs, quickActionMode } = useOverlayPrefs(); const { cardPresentation } = useUICustomization(); const posterWidthClasses = carouselCardWidthClasses( cardPresentation.poster_size, @@ -210,7 +212,11 @@ export default function Recommendations() { className={posterWidthClasses} role="listitem" > - +
))} diff --git a/web/src/pages/RecommendationsSection.tsx b/web/src/pages/RecommendationsSection.tsx index e5acef375..68e6371b3 100644 --- a/web/src/pages/RecommendationsSection.tsx +++ b/web/src/pages/RecommendationsSection.tsx @@ -6,6 +6,7 @@ import SectionItemCard from "@/components/SectionItemCard"; import { Skeleton } from "@/components/ui/skeleton"; import { useRecommendationSection } from "@/hooks/queries/recommendations"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; +import { useOverlayPrefs } from "@/hooks/useOverlayPrefs"; import { useUICustomization } from "@/hooks/useUICustomization"; import { cardGridClasses } from "@/lib/uiCustomization"; @@ -81,6 +82,7 @@ export default function RecommendationsSection() { key, ); const title = data?.label || fallbackTitle(kind, key); + const { prefs: overlayPrefs, quickActionMode } = useOverlayPrefs(); const { cardPresentation } = useUICustomization(); useDocumentTitle(title); @@ -108,7 +110,12 @@ export default function RecommendationsSection() { ) : (
{data.items.map((item) => ( - + ))}
)} diff --git a/web/src/pages/SettingsLayout.tsx b/web/src/pages/SettingsLayout.tsx index 0789a2e6d..3ebe2e8c9 100644 --- a/web/src/pages/SettingsLayout.tsx +++ b/web/src/pages/SettingsLayout.tsx @@ -609,10 +609,6 @@ export default function SettingsLayout() { [canManageProfiles], ); - const flatItems = useMemo( - () => visibleSections.flatMap((section) => section.items), - [visibleSections], - ); const filteredSections = useMemo( () => filterSettingsSearchGroups(visibleSections, settingsSearch), [settingsSearch, visibleSections], @@ -650,7 +646,6 @@ export default function SettingsLayout() { value={settingsSearch} onChange={setSettingsSearch} resultCount={filteredSettingsCount} - totalCount={flatItems.length} className="w-full sm:max-w-sm" shortcutMediaQuery={ activeSegment ? "(min-width: 64rem)" : undefined @@ -726,7 +721,6 @@ export default function SettingsLayout() { value={settingsSearch} onChange={setSettingsSearch} resultCount={filteredSettingsCount} - totalCount={flatItems.length} className="w-full sm:max-w-sm lg:w-[26rem] lg:max-w-none" showShortcutHint /> diff --git a/web/src/pages/admin-settings/AIServicesSettings.test.tsx b/web/src/pages/admin-settings/AIServicesSettings.test.tsx deleted file mode 100644 index 695dc5bd5..000000000 --- a/web/src/pages/admin-settings/AIServicesSettings.test.tsx +++ /dev/null @@ -1,276 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { renderToStaticMarkup } from "react-dom/server"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import AIServicesSettings from "./AIServicesSettings"; - -const mocks = vi.hoisted(() => ({ - checkConnection: vi.fn(), - discard: vi.fn(), - save: vi.fn(), - setValue: vi.fn(), - toastError: vi.fn(), -})); - -const values: Record = { - "ai.base_url": "https://text.example.test", - "ai.chat_model": "chat-model", - "ai.asr_base_url": "", - "ai.asr_model": "whisper-model", - "ai.max_concurrent_jobs": "2", - "subtitle_ai.base_url": "https://legacy.example.test", - "subtitle_ai.chat_model": "legacy-chat-model", - "subtitle_ai.max_concurrent_jobs": "3", - "subtitle_ai.enabled": "true", - "subtitle_ai.transcribe_enabled": "false", - "subtitle_ai.batch_size": "40", - "subtitle_ai.context_neighbors": "2", - "subtitle_ai.asr_chunk_seconds": "600", - "subtitle_ai.transcribe_quota_jobs": "0", - "subtitle_ai.transcribe_quota_period": "day", - "metadata_ai.enabled": "false", - "metadata_ai.on_view": "button", -}; - -let dirtyCount = 0; - -const useSettingsFormMock = vi.fn((_options?: { keys: string[] }) => ({ - isLoading: false, - getValue: (key: string) => values[key] ?? "", - setValue: mocks.setValue, - dirtyCount, - dirtyKeys: [], - isDirty: vi.fn(() => false), - save: mocks.save, - discard: mocks.discard, - isSaving: false, - restartRequired: false, - sensitiveConfigured: ["subtitle_ai.api_key"], - sensitiveManagedByEnv: [], - buildConnectionCheckRequest: vi.fn(() => ({ values: {}, dirty_keys: [] })), -})); - -vi.mock("@/hooks/useSettingsForm", () => ({ - useSettingsForm: (options: { keys: string[] }) => - useSettingsFormMock(options), -})); - -vi.mock("@/hooks/queries/admin/settings", () => ({ - useAdminServerSettings: () => ({ data: values }), - useAdminSensitiveStatus: () => ({ data: { configured: ["ai.api_key"] } }), - useUpdateServerSetting: () => ({ mutateAsync: vi.fn(), isPending: false }), - useCheckAdminSettingsConnection: () => ({ - mutateAsync: mocks.checkConnection, - isPending: false, - }), -})); - -vi.mock("sonner", () => ({ - toast: { - error: mocks.toastError, - }, -})); - -describe("AIServicesSettings", () => { - beforeEach(() => { - dirtyCount = 0; - mocks.checkConnection.mockReset(); - mocks.discard.mockReset(); - mocks.save.mockReset(); - mocks.setValue.mockReset(); - mocks.toastError.mockReset(); - values["ai.base_url"] = "https://text.example.test"; - values["ai.chat_model"] = "chat-model"; - values["ai.asr_base_url"] = ""; - values["ai.asr_model"] = "whisper-model"; - values["ai.max_concurrent_jobs"] = "2"; - values["subtitle_ai.batch_size"] = "40"; - values["subtitle_ai.context_neighbors"] = "2"; - values["subtitle_ai.asr_chunk_seconds"] = "600"; - }); - - it("separates text translation from speech-to-text configuration", () => { - const markup = renderToStaticMarkup(); - - expect(markup).toContain("Text translation"); - expect(markup).toContain("Speech-to-text"); - expect(markup).toContain("Test Text AI"); - expect(markup).toContain("Test Speech-to-Text"); - expect(markup).toContain("Uses the Text translation endpoint"); - }); - - it("shows effective legacy endpoint values until modern keys are saved", () => { - const currentBaseURL = values["ai.base_url"]!; - const currentChatModel = values["ai.chat_model"]!; - values["ai.base_url"] = ""; - values["ai.chat_model"] = ""; - - try { - const markup = renderToStaticMarkup(); - - expect(markup).toContain("https://legacy.example.test"); - expect(markup).toContain("legacy-chat-model"); - } finally { - values["ai.base_url"] = currentBaseURL; - values["ai.chat_model"] = currentChatModel; - } - }); - - it("marks known chat-only fallback endpoints as incompatible with speech-to-text", () => { - const currentBaseURL = values["ai.base_url"]!; - values["ai.base_url"] = "https://openrouter.ai/api"; - - try { - const markup = renderToStaticMarkup(); - - expect(markup).toContain("Incompatible endpoint"); - } finally { - values["ai.base_url"] = currentBaseURL; - } - }); - - it("exposes transcription preset selection to assistive technology", () => { - const markup = renderToStaticMarkup(); - - expect(markup).toContain('aria-pressed="false"'); - }); - - it("explains feature dependencies and keeps advanced tuning secondary", () => { - const markup = renderToStaticMarkup(); - - expect(markup).toContain("Text AI required"); - expect(markup).toContain("Speech-to-text required"); - expect(markup).toContain( - "Inactive until Description translation is enabled", - ); - expect(markup).toContain("Advanced"); - }); - - it("points recommendation embeddings to their separate configuration", () => { - const markup = renderToStaticMarkup(); - - expect(markup).toContain( - "Recommendation embeddings are configured separately", - ); - expect(markup).toContain('href="/admin/recommendations"'); - expect(markup).not.toContain("Changes take effect after a server restart"); - }); - - it("applies a transcription preset", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole("button", { name: "Groq - fast" })); - - expect(mocks.setValue).toHaveBeenCalledWith( - "ai.asr_base_url", - "https://api.groq.com/openai", - ); - expect(mocks.setValue).toHaveBeenCalledWith( - "ai.asr_model", - "whisper-large-v3-turbo", - ); - }); - - it("runs both connection checks and clears their results when drafts are discarded", async () => { - const user = userEvent.setup(); - dirtyCount = 1; - mocks.checkConnection - .mockResolvedValueOnce({ - success: true, - message: "Text connection verified.", - }) - .mockResolvedValueOnce({ - success: true, - message: "Speech connection verified.", - }); - render(); - - await user.click(screen.getByRole("button", { name: "Test Text AI" })); - await user.click( - screen.getByRole("button", { name: "Test Speech-to-Text" }), - ); - expect( - await screen.findByText("Text connection verified."), - ).toBeInTheDocument(); - expect( - await screen.findByText("Speech connection verified."), - ).toBeInTheDocument(); - expect(mocks.checkConnection).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ kind: "ai_chat" }), - ); - expect(mocks.checkConnection).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ kind: "ai_transcription" }), - ); - - await user.click(screen.getByRole("button", { name: "Discard" })); - - expect(mocks.discard).toHaveBeenCalledOnce(); - await waitFor(() => { - expect( - screen.queryByText("Text connection verified."), - ).not.toBeInTheDocument(); - expect( - screen.queryByText("Speech connection verified."), - ).not.toBeInTheDocument(); - }); - }); - - it("clears a prior connection result when its endpoint changes", async () => { - const user = userEvent.setup(); - mocks.checkConnection.mockResolvedValue({ - success: true, - message: "Text connection verified.", - }); - render(); - - await user.click(screen.getByRole("button", { name: "Test Text AI" })); - expect( - await screen.findByText("Text connection verified."), - ).toBeInTheDocument(); - await user.clear(screen.getByRole("textbox", { name: "Base URL" })); - - expect( - screen.queryByText("Text connection verified."), - ).not.toBeInTheDocument(); - }); - - it.each([ - [ - "ai.max_concurrent_jobs", - "1.5", - "Max concurrent jobs must be a positive whole number.", - ], - [ - "subtitle_ai.batch_size", - "2abc", - "Subtitle batch size must be a positive whole number.", - ], - [ - "subtitle_ai.context_neighbors", - "1.5", - "Subtitle context lines must be zero or a positive whole number.", - ], - [ - "subtitle_ai.asr_chunk_seconds", - "120seconds", - "Transcription chunk length must be between 60 and 600 seconds.", - ], - ])( - "rejects malformed integer input for %s", - async (key, malformedValue, message) => { - const user = userEvent.setup(); - dirtyCount = 1; - values[key] = malformedValue; - render(); - - await user.click(screen.getByRole("button", { name: "Save Changes" })); - - expect(mocks.toastError).toHaveBeenCalledWith(message); - expect(mocks.save).not.toHaveBeenCalled(); - }, - ); -}); diff --git a/web/src/pages/admin-settings/AIServicesSettings.tsx b/web/src/pages/admin-settings/AIServicesSettings.tsx deleted file mode 100644 index ec8f4f77d..000000000 --- a/web/src/pages/admin-settings/AIServicesSettings.tsx +++ /dev/null @@ -1,671 +0,0 @@ -import { useState } from "react"; -import { - AudioLines, - ChevronDown, - CircleAlert, - CircleCheck, - ExternalLink, - Languages, -} from "lucide-react"; -import { toast } from "sonner"; - -import type { ConnectionCheckResponse } from "@/api/types"; -import { ConnectionCheckAction } from "@/components/admin/ConnectionCheckAction"; -import { Badge } from "@/components/ui/badge"; -import { Skeleton } from "@/components/ui/skeleton"; -import { useCheckAdminSettingsConnection } from "@/hooks/queries/admin/settings"; -import { useSettingsForm } from "@/hooks/useSettingsForm"; -import { QUOTA_PERIODS, QUOTA_PERIOD_WINDOW_LABELS } from "@/lib/quotaPeriods"; -import { cn } from "@/lib/utils"; - -import { SaveBar } from "./SaveBar"; -import { SettingField } from "./SettingField"; - -const TEXT_AI_KEYS = ["ai.base_url", "ai.chat_model", "ai.api_key"] as const; -const SPEECH_AI_KEYS = [ - "ai.base_url", - "ai.api_key", - "ai.asr_base_url", - "ai.asr_model", - "ai.asr_api_key", -] as const; -const LEGACY_AI_KEYS = [ - "subtitle_ai.base_url", - "subtitle_ai.api_key", - "subtitle_ai.chat_model", - "subtitle_ai.max_concurrent_jobs", -] as const; -const KEYS: string[] = [ - ...TEXT_AI_KEYS, - ...LEGACY_AI_KEYS, - "ai.asr_base_url", - "ai.asr_model", - "ai.asr_api_key", - "ai.max_concurrent_jobs", - "subtitle_ai.enabled", - "subtitle_ai.transcribe_enabled", - "subtitle_ai.batch_size", - "subtitle_ai.context_neighbors", - "subtitle_ai.asr_chunk_seconds", - "subtitle_ai.transcribe_quota_jobs", - "subtitle_ai.transcribe_quota_period", - "metadata_ai.enabled", - "metadata_ai.on_view", -]; - -const TRANSCRIPTION_PRESETS = [ - { - id: "self-hosted", - label: "Self-hosted", - description: - "Speaches or faster-whisper on your network. Replace the hostname with one reachable from the Prairie container.", - baseUrl: "http://speaches:8000", - model: "deepdml/faster-whisper-large-v3-turbo-ct2", - }, - { - id: "groq-turbo", - label: "Groq - fast", - description: "Hosted whisper-large-v3-turbo. Requires a Groq API key.", - baseUrl: "https://api.groq.com/openai", - model: "whisper-large-v3-turbo", - }, - { - id: "groq-accurate", - label: "Groq - accurate", - description: "Hosted whisper-large-v3. Requires a Groq API key.", - baseUrl: "https://api.groq.com/openai", - model: "whisper-large-v3", - }, - { - id: "openai", - label: "OpenAI", - description: - "Hosted whisper-1. The transcription key can inherit the Text AI key.", - baseUrl: "https://api.openai.com", - model: "whisper-1", - }, -] as const; - -const CHAT_ONLY_GATEWAY_HOSTS = ["openrouter.ai"]; - -function isChatOnlyGateway(rawURL: string): boolean { - const trimmed = rawURL.trim(); - if (!trimmed) return false; - try { - const host = new URL( - trimmed.includes("://") ? trimmed : `https://${trimmed}`, - ).hostname.toLowerCase(); - return CHAT_ONLY_GATEWAY_HOSTS.some( - (gateway) => host === gateway || host.endsWith(`.${gateway}`), - ); - } catch { - return false; - } -} - -function parseStrictInteger(rawValue: string): number | null { - const trimmed = rawValue.trim(); - if (!/^-?\d+$/.test(trimmed)) return null; - const parsed = Number(trimmed); - return Number.isSafeInteger(parsed) ? parsed : null; -} - -function SectionHeading({ - icon: Icon, - title, - description, - status, - statusTone = "neutral", -}: { - icon: typeof Languages; - title: string; - description: string; - status: string; - statusTone?: "ready" | "warning" | "neutral"; -}) { - return ( -
-
-
- -
-
-

{title}

-

- {description} -

-
-
- - {statusTone === "ready" ? ( - - ) : statusTone === "warning" ? ( - - ) : null} - {status} - -
- ); -} - -function RequirementNote({ - label, - ready, - detail, -}: { - label: string; - ready: boolean; - detail: string; -}) { - return ( -
- {ready ? ( - - ) : ( - - )} - - {label} - {detail} - -
- ); -} - -export default function AIServicesSettings() { - const form = useSettingsForm({ keys: KEYS }); - const textCheck = useCheckAdminSettingsConnection(); - const speechCheck = useCheckAdminSettingsConnection(); - const [textResult, setTextResult] = useState( - null, - ); - const [speechResult, setSpeechResult] = - useState(null); - - if (form.isLoading) { - return ( -
- - - - - Loading AI settings -
- ); - } - - const value = (key: string, fallback = "") => form.getValue(key) || fallback; - const effectiveValue = (key: string, legacyKey: string, fallback: string) => - value(key, value(legacyKey, fallback)); - const textBaseURL = effectiveValue( - "ai.base_url", - "subtitle_ai.base_url", - "https://api.openai.com", - ); - const chatModel = effectiveValue( - "ai.chat_model", - "subtitle_ai.chat_model", - "gpt-4o-mini", - ); - const asrBaseURL = value("ai.asr_base_url"); - const asrModel = value("ai.asr_model", "whisper-1"); - const textReady = textBaseURL.trim() !== "" && chatModel.trim() !== ""; - const speechUsesTextEndpoint = asrBaseURL.trim() === ""; - const speechCheckable = - (asrBaseURL.trim() !== "" || textBaseURL.trim() !== "") && - asrModel.trim() !== ""; - const speechCompatible = !isChatOnlyGateway( - speechUsesTextEndpoint ? textBaseURL : asrBaseURL, - ); - const speechReady = speechCheckable && speechCompatible; - const descriptionEnabled = value("metadata_ai.enabled", "false") === "true"; - - function setValue(key: string, nextValue: string) { - form.setValue(key, nextValue); - if (TEXT_AI_KEYS.includes(key as (typeof TEXT_AI_KEYS)[number])) { - setTextResult(null); - } - if (SPEECH_AI_KEYS.includes(key as (typeof SPEECH_AI_KEYS)[number])) { - setSpeechResult(null); - } - } - - async function checkTextConnection() { - try { - setTextResult( - await textCheck.mutateAsync({ - kind: "ai_chat", - body: form.buildConnectionCheckRequest([...TEXT_AI_KEYS]), - }), - ); - } catch (error) { - setTextResult({ - success: false, - message: - error instanceof Error - ? error.message - : "Text AI connection check failed.", - }); - } - } - - async function checkSpeechConnection() { - try { - setSpeechResult( - await speechCheck.mutateAsync({ - kind: "ai_transcription", - body: form.buildConnectionCheckRequest([...SPEECH_AI_KEYS]), - }), - ); - } catch (error) { - setSpeechResult({ - success: false, - message: - error instanceof Error - ? error.message - : "Speech-to-text connection check failed.", - }); - } - } - - async function save() { - const batchSize = parseStrictInteger(value("subtitle_ai.batch_size", "40")); - const contextLines = parseStrictInteger( - value("subtitle_ai.context_neighbors", "2"), - ); - const chunkSeconds = parseStrictInteger( - value("subtitle_ai.asr_chunk_seconds", "600"), - ); - const quotaJobs = Number.parseInt( - value("subtitle_ai.transcribe_quota_jobs", "0"), - 10, - ); - const maxConcurrent = parseStrictInteger( - effectiveValue( - "ai.max_concurrent_jobs", - "subtitle_ai.max_concurrent_jobs", - "2", - ), - ); - - if (!textReady) { - toast.error("Text AI base URL and chat model are required."); - return; - } - if (maxConcurrent === null || maxConcurrent < 1) { - toast.error("Max concurrent jobs must be a positive whole number."); - return; - } - if (batchSize === null || batchSize < 1) { - toast.error("Subtitle batch size must be a positive whole number."); - return; - } - if (contextLines === null || contextLines < 0) { - toast.error( - "Subtitle context lines must be zero or a positive whole number.", - ); - return; - } - if (chunkSeconds === null || chunkSeconds < 60 || chunkSeconds > 600) { - toast.error( - "Transcription chunk length must be between 60 and 600 seconds.", - ); - return; - } - if (!Number.isInteger(quotaJobs) || quotaJobs < 0) { - toast.error( - "Transcription limit must be zero or a positive whole number.", - ); - return; - } - await form.save(); - } - - function discard() { - form.discard(); - setTextResult(null); - setSpeechResult(null); - } - - return ( -
-
-

AI Services

-

- Configure text translation and speech-to-text independently, then - enable only the features that use them. -

-
- -
-
- -
- setValue("ai.base_url", next)} - hint="https://api.openai.com" - /> - setValue("ai.chat_model", next)} - hint="gpt-4o-mini, gemini-flash-latest, llama3.1" - /> - setValue("ai.api_key", next)} - sensitiveConfigured={ - form.sensitiveConfigured.includes("ai.api_key") || - form.sensitiveConfigured.includes("subtitle_ai.api_key") - } - hint="Optional for keyless local endpoints. Saved keys are reused for tests only when the endpoint host is unchanged." - /> - -
-
- -
- -
-
- {TRANSCRIPTION_PRESETS.map((preset) => { - const active = - asrBaseURL === preset.baseUrl && asrModel === preset.model; - return ( - - ); - })} -
- setValue("ai.asr_base_url", next)} - hint="http://speaches:8000 or https://api.groq.com/openai" - /> - {speechUsesTextEndpoint && ( -
- - - Uses the Text translation endpoint and API key. This only - works when that provider implements OpenAI-compatible{" "} - /audio/transcriptions with timestamped segments. - Test it before enabling audio generation. - -
- )} - setValue("ai.asr_model", next)} - hint="whisper-large-v3-turbo or whisper-1" - /> - setValue("ai.asr_api_key", next)} - sensitiveConfigured={form.sensitiveConfigured.includes( - "ai.asr_api_key", - )} - hint="Optional. A saved or inherited key is reused for tests only when the endpoint host is unchanged." - /> -

- For self-hosted services, use a hostname or IP reachable from the - Prairie container. - localhost - points back to Prairie itself. -

- -
-
- -
-
-

Features

-

- Generated subtitles and translated metadata are saved once and - served to every client through Prairie's normal pipelines. -

-
- -
-
- setValue("subtitle_ai.enabled", next)} - hint="Text AI required - Translates an existing text subtitle track. Whisper is not used." - /> - -
-
- - setValue("subtitle_ai.transcribe_enabled", next) - } - hint="Speech-to-text required - Uses Whisper to create timed subtitles from the selected audio track." - /> - -
-
- setValue("metadata_ai.enabled", next)} - hint="Text AI required - Translates overviews and taglines from the metadata editor or library refresh." - /> - -
-
- setValue("metadata_ai.on_view", next)} - disabled={!descriptionEnabled} - options={[ - { value: "off", label: "Off" }, - { - value: "button", - label: "Translate button on detail pages", - }, - { value: "auto", label: "Automatic on view" }, - ]} - hint={ - descriptionEnabled - ? "Controls viewer-triggered description translation." - : "Inactive until Description translation is enabled." - } - /> -
-
-
- -
-
- -
-

Advanced

-

- Job concurrency, translation batching, transcription chunks, - and account quotas. -

-
- -
-
- setValue("ai.max_concurrent_jobs", next)} - hint="Shared by subtitle translation, speech-to-text, and description translation. Changing this value requires a server restart." - /> - setValue("subtitle_ai.batch_size", next)} - hint="Text cues sent in each translation request." - /> - - setValue("subtitle_ai.context_neighbors", next) - } - hint="Previous source cues included for scene continuity." - /> - - setValue("subtitle_ai.asr_chunk_seconds", next) - } - hint="60-600. Shorter chunks reduce timestamp drift but make more requests." - /> - - setValue("subtitle_ai.transcribe_quota_jobs", next) - } - hint="0 = unlimited. Profiles share their account's limit." - /> - - setValue("subtitle_ai.transcribe_quota_period", next) - } - options={QUOTA_PERIODS.map((period) => ({ - value: period, - label: `Per ${period} (rolling ${QUOTA_PERIOD_WINDOW_LABELS[period]})`, - }))} - hint="Rolling window used for the account limit." - /> -
-
-
-
- -
-
-

- Recommendation embeddings are configured separately -

-

- Search vectors and recommendations do not use the translation or - speech endpoints above. -

-
- - Open Recommendations - - -
- - void save()} - onDiscard={discard} - isSaving={form.isSaving} - restartRequired={form.restartRequired} - /> -
- ); -} diff --git a/web/src/pages/admin-settings/AISettings.test.tsx b/web/src/pages/admin-settings/AISettings.test.tsx new file mode 100644 index 000000000..1bd7f863d --- /dev/null +++ b/web/src/pages/admin-settings/AISettings.test.tsx @@ -0,0 +1,478 @@ +import { render as renderDOM, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import AISettings from "./AISettings"; + +// The page links to Recommendations with a router , so it needs a router. +function render(ui: React.ReactElement) { + return renderDOM({ui}); +} + +const mocks = vi.hoisted(() => ({ + checkConnection: vi.fn(), + discard: vi.fn(), + save: vi.fn(), + setValue: vi.fn(), + resetValue: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), +})); + +const values: Record = {}; + +const DEFAULT_VALUES: Record = { + "ai.base_url": "https://text.example.test", + "ai.chat_model": "chat-model", + "ai.asr_base_url": "", + "ai.asr_model": "whisper-model", + "ai.max_concurrent_jobs": "2", + "subtitle_ai.base_url": "https://legacy.example.test", + "subtitle_ai.chat_model": "legacy-chat-model", + "subtitle_ai.max_concurrent_jobs": "3", + "subtitle_ai.enabled": "true", + "subtitle_ai.transcribe_enabled": "false", + "subtitle_ai.batch_size": "40", + "subtitle_ai.context_neighbors": "2", + "subtitle_ai.asr_chunk_seconds": "600", + "subtitle_ai.transcribe_quota_jobs": "0", + "subtitle_ai.transcribe_quota_period": "day", + "metadata_ai.enabled": "false", + "metadata_ai.on_view": "button", +}; + +let dirtyCount = 0; +let dirtyKeys: string[] = []; +const DEFAULT_SENSITIVE_CONFIGURED = ["subtitle_ai.api_key"]; +let sensitiveConfigured: string[] = DEFAULT_SENSITIVE_CONFIGURED; + +const useSettingsFormMock = vi.fn((_options?: { keys: string[] }) => ({ + isLoading: false, + getValue: (key: string) => values[key] ?? "", + setValue: mocks.setValue, + resetValue: mocks.resetValue, + dirtyCount, + dirtyKeys, + isDirty: (key: string) => dirtyKeys.includes(key), + isClearStaged: (key: string) => + dirtyKeys.includes(key) && (values[key] ?? "") === "", + save: mocks.save, + discard: mocks.discard, + isSaving: false, + restartRequired: false, + sensitiveConfigured, + sensitiveManagedByEnv: [], + sensitiveStatusReady: true, + sensitiveStatusError: false, + buildConnectionCheckRequest: vi.fn(() => ({ values: {}, dirty_keys: [] })), +})); + +vi.mock("@/hooks/useSettingsForm", () => ({ + useSettingsForm: (options: { keys: string[] }) => + useSettingsFormMock(options), +})); + +vi.mock("@/hooks/useRestartKeys", () => ({ + useRestartKeys: () => new Set(["ai.max_concurrent_jobs"]), +})); + +vi.mock("@/hooks/queries/admin/settings", () => ({ + useCheckAdminSettingsConnection: () => ({ + mutateAsync: mocks.checkConnection, + isPending: false, + }), +})); + +vi.mock("sonner", () => ({ + toast: { + error: mocks.toastError, + success: mocks.toastSuccess, + }, +})); + +/** Opens a model tile's connect panel. */ +async function openTile( + user: ReturnType, + name: string, +) { + const tile = screen.getByRole("group", { name }); + await user.click( + within(tile).getByRole("button", { name: /Connect|Manage/ }), + ); + return screen.getByRole("group", { name }); +} + +describe("AISettings", () => { + beforeEach(() => { + localStorage.clear(); + dirtyCount = 0; + dirtyKeys = []; + sensitiveConfigured = DEFAULT_SENSITIVE_CONFIGURED; + for (const mock of Object.values(mocks)) mock.mockReset(); + for (const key of Object.keys(values)) delete values[key]; + Object.assign(values, DEFAULT_VALUES); + }); + + it("heads the page and groups models and features", () => { + render(); + + expect( + screen.getByRole("heading", { level: 1, name: "AI Services" }), + ).toBeInTheDocument(); + expect( + screen.queryByText( + "Optional language models for subtitle translation, transcription, and descriptions.", + ), + ).not.toBeInTheDocument(); + expect(screen.getByRole("group", { name: "Models" })).toBeInTheDocument(); + expect(screen.getByRole("group", { name: "Features" })).toBeInTheDocument(); + expect( + screen.getByRole("group", { name: "Text model" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("group", { name: "Speech-to-text" }), + ).toBeInTheDocument(); + }); + + it("keeps model credentials behind the tile until it is expanded", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByLabelText("Model")).not.toBeInTheDocument(); + + const tile = await openTile(user, "Text model"); + expect(tile).toHaveAttribute("data-expanded", "true"); + expect(within(tile).getByLabelText("Base URL")).toBeInTheDocument(); + expect(within(tile).getByLabelText("Model")).toBeInTheDocument(); + + await user.click(within(tile).getByRole("button", { name: "Close" })); + expect(screen.queryByLabelText("Base URL")).not.toBeInTheDocument(); + }); + + it("falls back to the legacy subtitle_ai values", async () => { + const user = userEvent.setup(); + values["ai.base_url"] = ""; + values["ai.chat_model"] = ""; + + render(); + await openTile(user, "Text model"); + + expect( + screen.getByDisplayValue("https://legacy.example.test"), + ).toBeInTheDocument(); + expect(screen.getByDisplayValue("legacy-chat-model")).toBeInTheDocument(); + }); + + it("flags a chat-only endpoint as unable to transcribe", () => { + values["ai.base_url"] = "https://openrouter.ai/api"; + + render(); + + expect(screen.getByText("Cannot transcribe")).toBeInTheDocument(); + expect( + screen.getByRole("group", { name: "Speech-to-text" }), + ).toHaveAttribute("data-state", "error"); + }); + + it("applies a speech-to-text preset", async () => { + const user = userEvent.setup(); + render(); + await openTile(user, "Speech-to-text"); + + await user.click(screen.getByRole("button", { name: "Groq - fast" })); + + expect(mocks.setValue).toHaveBeenCalledWith( + "ai.asr_base_url", + "https://api.groq.com/openai", + ); + expect(mocks.setValue).toHaveBeenCalledWith( + "ai.asr_model", + "whisper-large-v3-turbo", + ); + }); + + it("forces a tile open while it holds a staged change", () => { + dirtyKeys = ["ai.chat_model"]; + dirtyCount = 1; + + render(); + + expect(screen.getByRole("group", { name: "Text model" })).toHaveAttribute( + "data-expanded", + "true", + ); + expect(screen.getByLabelText("Model")).toBeInTheDocument(); + }); + + it("leaves the speech tile closed when only the shared text endpoint is staged", () => { + // The transcription check falls back to the text endpoint, so its keys are + // part of that request — but they are edited in the text tile, not here. + dirtyKeys = ["ai.base_url", "ai.api_key"]; + dirtyCount = 2; + + render(); + + expect(screen.getByRole("group", { name: "Text model" })).toHaveAttribute( + "data-expanded", + "true", + ); + expect( + screen.getByRole("group", { name: "Speech-to-text" }), + ).not.toHaveAttribute("data-expanded"); + }); + + it("gives the model panel actions a resting affordance instead of ghost text", async () => { + const user = userEvent.setup(); + render(); + + const tile = await openTile(user, "Text model"); + expect( + within(tile).getByRole("button", { name: "Test text model" }), + ).toHaveAttribute("data-variant", "secondary"); + expect(within(tile).getByRole("button", { name: "Close" })).toHaveAttribute( + "data-variant", + "outline", + ); + }); + + it("says the features run on demand rather than on a schedule", () => { + render(); + + expect( + screen.getByText(/Nothing here runs on a schedule/), + ).toBeInTheDocument(); + }); + + it("blocks turning on a feature whose model cannot serve it", () => { + // A chat-only endpoint cannot transcribe, so speech-to-text is not ready. + values["ai.base_url"] = "https://openrouter.ai/api"; + + render(); + + expect( + screen.getByRole("switch", { name: "Create subtitles from audio" }), + ).toBeDisabled(); + }); + + it("still lets an enabled feature be turned off after its model degrades", () => { + values["ai.base_url"] = "https://openrouter.ai/api"; + values["subtitle_ai.transcribe_enabled"] = "true"; + + render(); + + expect( + screen.getByRole("switch", { name: "Create subtitles from audio" }), + ).toBeEnabled(); + }); + + it("says nothing under a feature whose model is ready", () => { + render(); + + expect(screen.getByText("Translate subtitles")).toBeInTheDocument(); + expect(screen.queryByText("Needs the text model")).not.toBeInTheDocument(); + expect(screen.queryByText("Needs speech-to-text")).not.toBeInTheDocument(); + }); + + it("names the missing model when a feature cannot run", () => { + values["ai.base_url"] = "https://openrouter.ai/api"; + + render(); + + // A chat-only endpoint cannot transcribe, so the speech feature is unmet. + expect(screen.getByText("Needs speech-to-text")).toBeInTheDocument(); + }); + + it("keeps AI tuning behind a collapsed advanced disclosure", async () => { + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole("button", { + name: /Advanced · 6 settings/, + }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect( + screen.queryByLabelText("Jobs running at once"), + ).not.toBeInTheDocument(); + + await user.click(toggle); + + expect(screen.getByLabelText("Jobs running at once")).toBeInTheDocument(); + // Restart-only keys carry the badge instead of hint text. + expect( + screen.getAllByLabelText("Takes effect after a server restart").length, + ).toBe(1); + }); + + it("separates server-wide tuning from the per-account limit", async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: /Advanced · 6 settings/ }), + ); + + expect( + screen.getByRole("heading", { name: "Server-wide tuning" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Per-account limits" }), + ).toBeInTheDocument(); + expect( + screen.getByText( + "Counted per login account, shared by every profile on it.", + ), + ).toBeInTheDocument(); + }); + + it("auto-expands the advanced section around a staged change", () => { + dirtyKeys = ["subtitle_ai.batch_size"]; + dirtyCount = 1; + + render(); + + // A staged change auto-expands the section so the save bar cannot block on + // a hidden field. + expect( + screen.getByRole("button", { name: /Advanced · 6 settings/ }), + ).toBeInTheDocument(); + expect( + screen.getByLabelText("Subtitle lines per request"), + ).toBeInTheDocument(); + }); + + it("offers Unlimited instead of a zero sentinel for the transcription allowance", async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: /Advanced · 6 settings/ }), + ); + + expect(screen.getByRole("checkbox", { name: "Unlimited" })).toBeChecked(); + }); + + it.each([ + [ + "ai.max_concurrent_jobs", + "1.5", + "Max concurrent jobs must be a positive whole number.", + ], + [ + "subtitle_ai.batch_size", + "2abc", + "Subtitle batch size must be a positive whole number.", + ], + [ + "subtitle_ai.context_neighbors", + "1.5", + "Subtitle context lines must be zero or a positive whole number.", + ], + [ + "subtitle_ai.asr_chunk_seconds", + "120seconds", + "Transcription chunk length must be between 60 and 600 seconds.", + ], + // parseInt would stop at the first non-digit and let "5abc" through as 5, + // saving a quota nobody typed. + [ + "subtitle_ai.transcribe_quota_jobs", + "5abc", + "Transcription limit must be zero or a positive whole number.", + ], + [ + "subtitle_ai.transcribe_quota_jobs", + "1.5", + "Transcription limit must be zero or a positive whole number.", + ], + ])( + "rejects malformed integer input for %s", + async (key, malformedValue, message) => { + const user = userEvent.setup(); + dirtyCount = 1; + values[key] = malformedValue; + render(); + + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(mocks.toastError).toHaveBeenCalledWith(message); + expect(mocks.save).not.toHaveBeenCalled(); + }, + ); + + it("runs the text model check against the staged values", async () => { + const user = userEvent.setup(); + mocks.checkConnection.mockResolvedValue({ + success: true, + message: "Text connection verified.", + }); + render(); + await openTile(user, "Text model"); + + await user.click(screen.getByRole("button", { name: "Test text model" })); + + expect( + await screen.findByText(/Text connection verified\./), + ).toBeInTheDocument(); + expect(mocks.checkConnection).toHaveBeenCalledWith( + expect.objectContaining({ kind: "ai_chat" }), + ); + }); + + it("emptying a saved AI key reverts the draft instead of staging an empty value", async () => { + const user = userEvent.setup(); + values["ai.api_key"] = "draft"; + render(); + const tile = await openTile(user, "Text model"); + + // No Replace step: the saved key is a masked, always-editable input. + const input = within(tile).getByLabelText("API key"); + expect(input).toHaveAttribute("placeholder", "••••••••••••"); + await user.clear(input); + + // Staging "" would erase the stored key on the next save. + expect(mocks.setValue).not.toHaveBeenCalledWith("ai.api_key", ""); + expect(mocks.resetValue).toHaveBeenCalledWith("ai.api_key"); + }); + + it("clears the legacy key alongside the modern one", async () => { + const user = userEvent.setup(); + render(); + const tile = await openTile(user, "Text model"); + + await user.click( + within(tile).getByRole("button", { name: "Clear saved value" }), + ); + + // An empty `ai.api_key` falls back to `subtitle_ai.api_key`, so clearing + // only the modern key would leave the old secret in force. + expect(mocks.setValue).toHaveBeenCalledWith("ai.api_key", ""); + expect(mocks.setValue).toHaveBeenCalledWith("subtitle_ai.api_key", ""); + }); + + it("says what a staged clear of the speech key will do", async () => { + const user = userEvent.setup(); + dirtyKeys = ["ai.asr_api_key"]; + dirtyCount = 1; + values["ai.asr_api_key"] = ""; + sensitiveConfigured = ["ai.asr_api_key"]; + + render(); + const tile = screen.getByRole("group", { name: "Speech-to-text" }); + + // A staged edit holds its tile open, so no expansion step is needed. + expect(within(tile).getByLabelText("API key")).toHaveAttribute( + "placeholder", + "Will be cleared on save", + ); + expect( + within(tile).getByText( + "Save clears the stored value; type to set a new one instead.", + ), + ).toBeInTheDocument(); + await user.click( + within(tile).getByRole("button", { name: "Keep saved value" }), + ); + expect(mocks.resetValue).toHaveBeenCalledWith("ai.asr_api_key"); + }); +}); diff --git a/web/src/pages/admin-settings/AISettings.tsx b/web/src/pages/admin-settings/AISettings.tsx new file mode 100644 index 000000000..c038794a5 --- /dev/null +++ b/web/src/pages/admin-settings/AISettings.tsx @@ -0,0 +1,1005 @@ +import { useState, type ReactNode } from "react"; +import { Link } from "react-router"; +import { AudioLines, CircleAlert, Languages } from "lucide-react"; +import { toast } from "sonner"; + +import { AdvancedSection } from "@/components/settings/AdvancedSection"; +import { LimitField } from "@/components/settings/LimitField"; +import { + ProviderTile, + ProviderTileGrid, +} from "@/components/settings/ProviderTile"; +import type { ProviderTileState } from "@/components/settings/ProviderTile"; +import { SecretField } from "@/components/settings/SecretField"; +import { SettingsPageHeader } from "@/components/settings/SettingsPageHeader"; +import { SettingsSubheading } from "@/components/settings/SettingsSubheading"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useCheckAdminSettingsConnection } from "@/hooks/queries/admin/settings"; +import { useRestartKeys, type RestartKeyMatcher } from "@/hooks/useRestartKeys"; +import { useSettingsForm } from "@/hooks/useSettingsForm"; +import { QUOTA_PERIODS, QUOTA_PERIOD_WINDOW_LABELS } from "@/lib/quotaPeriods"; +import { cn } from "@/lib/utils"; + +import { FieldGroup } from "./FieldGroup"; +import { SaveBar } from "./SaveBar"; +import { SettingField, SettingFieldStatus } from "./SettingField"; + +// --------------------------------------------------------------------------- +// Setting keys +// --------------------------------------------------------------------------- + +const TEXT_AI_KEYS = ["ai.base_url", "ai.chat_model", "ai.api_key"] as const; +/** + * What the transcription connection check has to send: the speech endpoint + * plus the text endpoint it falls back to when no ASR base URL is set. + */ +const SPEECH_AI_KEYS = [ + "ai.base_url", + "ai.api_key", + "ai.asr_base_url", + "ai.asr_model", + "ai.asr_api_key", +] as const; +/** + * The keys the speech tile actually renders. Only these decide whether it is + * held open by a staged edit — the shared text keys are edited in the text + * tile, so counting them here would expand both tiles at once. + */ +const SPEECH_ONLY_KEYS = [ + "ai.asr_base_url", + "ai.asr_model", + "ai.asr_api_key", +] as const; +/** + * Pre-`ai.*` keys. They are still read as a fallback so a server that was + * configured before the rename keeps working until the modern key is saved. + */ +const LEGACY_AI_KEYS = [ + "subtitle_ai.base_url", + "subtitle_ai.api_key", + "subtitle_ai.chat_model", + "subtitle_ai.max_concurrent_jobs", +] as const; + +const AI_FEATURE_KEYS = [ + "subtitle_ai.enabled", + "subtitle_ai.transcribe_enabled", + "metadata_ai.enabled", + "metadata_ai.on_view", +]; + +const AI_ADVANCED_KEYS = [ + "ai.max_concurrent_jobs", + "subtitle_ai.batch_size", + "subtitle_ai.context_neighbors", + "subtitle_ai.asr_chunk_seconds", + "subtitle_ai.transcribe_quota_jobs", + "subtitle_ai.transcribe_quota_period", +]; + +const KEYS: string[] = Array.from( + new Set([ + ...TEXT_AI_KEYS, + ...SPEECH_AI_KEYS, + ...LEGACY_AI_KEYS, + ...AI_FEATURE_KEYS, + ...AI_ADVANCED_KEYS, + ]), +); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const TRANSCRIPTION_PRESETS = [ + { + id: "self-hosted", + label: "Self-hosted", + description: + "Speaches or faster-whisper on your network. Replace the hostname with one reachable from the Silo container.", + baseUrl: "http://speaches:8000", + model: "deepdml/faster-whisper-large-v3-turbo-ct2", + }, + { + id: "groq-turbo", + label: "Groq - fast", + description: "Hosted whisper-large-v3-turbo. Requires a Groq API key.", + baseUrl: "https://api.groq.com/openai", + model: "whisper-large-v3-turbo", + }, + { + id: "groq-accurate", + label: "Groq - accurate", + description: "Hosted whisper-large-v3. Requires a Groq API key.", + baseUrl: "https://api.groq.com/openai", + model: "whisper-large-v3", + }, + { + id: "openai", + label: "OpenAI", + description: + "Hosted whisper-1. The transcription key can inherit the Text AI key.", + baseUrl: "https://api.openai.com", + model: "whisper-1", + }, +] as const; + +const CHAT_ONLY_GATEWAY_HOSTS = ["openrouter.ai"]; + +function isChatOnlyGateway(rawURL: string): boolean { + const trimmed = rawURL.trim(); + if (!trimmed) return false; + try { + const host = new URL( + trimmed.includes("://") ? trimmed : `https://${trimmed}`, + ).hostname.toLowerCase(); + return CHAT_ONLY_GATEWAY_HOSTS.some( + (gateway) => host === gateway || host.endsWith(`.${gateway}`), + ); + } catch { + return false; + } +} + +function hostLabel(rawURL: string): string { + const trimmed = rawURL.trim(); + if (!trimmed) return ""; + try { + return new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`) + .host; + } catch { + return trimmed; + } +} + +function parseStrictInteger(rawValue: string): number | null { + const trimmed = rawValue.trim(); + if (!/^-?\d+$/.test(trimmed)) return null; + const parsed = Number(trimmed); + return Number.isSafeInteger(parsed) ? parsed : null; +} + +/** Result of the last connection check, kept in memory for the tile and strip. */ +interface AITestState { + ok: boolean; + message: string; + at: number; + durationMs: number; +} + +function testedLabel(test: AITestState): string { + const seconds = Math.max(0, Math.round((Date.now() - test.at) / 1000)); + const ago = + seconds < 60 ? `${seconds}s ago` : `${Math.round(seconds / 60)}m ago`; + return `Tested ${ago} · ${test.durationMs} ms`; +} + +/** + * The action row shared by both model tiles. Both controls carry a border or a + * fill at rest: a `ghost` button reads as plain text until it is hovered, which + * hid Close from admins who never hovered it. + */ +function ModelPanelActions({ + testLabel, + pendingLabel, + onTest, + isTesting, + testDisabled, + onCollapse, + canCollapse, + test, +}: { + testLabel: string; + pendingLabel: string; + onTest: () => void; + isTesting: boolean; + testDisabled: boolean; + onCollapse: () => void; + /** + * False while a staged edit holds the tile open. Collapsing then does + * nothing, so the button is left out rather than shown as a dead control. + */ + canCollapse: boolean; + test: AITestState | undefined; +}) { + return ( + // Buttons right-aligned to match the collapsed tile's Manage button (and + // the shared ProviderPanelActions); the test status takes the left side. +
+ {test ? ( + + {test.ok ? `${test.message} · ${testedLabel(test)}` : test.message} + + ) : null} + + {canCollapse ? ( + + ) : null} +
+ ); +} + +/** + * A labelled cluster inside the Advanced disclosure. The tuning fields mix two + * scopes — server-wide dispatch/batching and a per-login-account quota — and + * nothing on the row itself says which is which, so the scope is stated once + * per cluster instead of being repeated (or omitted) field by field. + * + * One element per cluster also means the disclosure's child rule draws a single + * hairline between the two, with the rows keeping their own inside each. + */ +function TuningScope({ + label, + caption, + children, +}: { + label: string; + caption: string; + children: ReactNode; +}) { + return ( +
+ {label} + {children} +
+ ); +} + +/** Note shown on a model tile whose values are staged in the page's save bar. */ +function PendingSaveNote({ dirty }: { dirty: boolean }) { + if (!dirty) return null; + return ( +

+ Unsaved. Test uses what is typed here. +

+ ); +} + +// --------------------------------------------------------------------------- +// Model tiles +// --------------------------------------------------------------------------- + +function TextModelTile({ + baseURL, + chatModel, + apiKeyValue, + apiKeyConfigured, + apiKeyCleared, + ready, + dirty, + restartKeys, + onChange, + onReset, + onClearApiKey, + onTest, + isTesting, + test, + expanded, + onExpand, + onCollapse, +}: { + baseURL: string; + chatModel: string; + apiKeyValue: string; + apiKeyConfigured: boolean; + apiKeyCleared: boolean; + ready: boolean; + dirty: boolean; + restartKeys: RestartKeyMatcher; + onChange: (key: string, value: string) => void; + onReset: (key: string) => void; + onClearApiKey: () => void; + onTest: () => void; + isTesting: boolean; + test: AITestState | undefined; + expanded: boolean; + onExpand: () => void; + onCollapse: () => void; +}) { + const failed = test != null && !test.ok; + const state: ProviderTileState = expanded + ? "editing" + : failed + ? "error" + : ready + ? "connected" + : "not_connected"; + + return ( + + ); +} + +function SpeechModelTile({ + asrBaseURL, + asrModel, + apiKeyValue, + apiKeyConfigured, + apiKeyCleared, + usesTextEndpoint, + compatible, + ready, + checkable, + dirty, + restartKeys, + onChange, + onReset, + onClearApiKey, + onTest, + isTesting, + test, + expanded, + onExpand, + onCollapse, +}: { + asrBaseURL: string; + asrModel: string; + apiKeyValue: string; + apiKeyConfigured: boolean; + apiKeyCleared: boolean; + usesTextEndpoint: boolean; + compatible: boolean; + ready: boolean; + checkable: boolean; + dirty: boolean; + restartKeys: RestartKeyMatcher; + onChange: (key: string, value: string) => void; + onReset: (key: string) => void; + onClearApiKey: () => void; + onTest: () => void; + isTesting: boolean; + test: AITestState | undefined; + expanded: boolean; + onExpand: () => void; + onCollapse: () => void; +}) { + const failed = test != null && !test.ok; + const statePill = !compatible + ? "Cannot transcribe" + : test?.ok + ? "Verified" + : usesTextEndpoint + ? "Shared endpoint" + : ready + ? "Configured" + : undefined; + const state: ProviderTileState = expanded + ? "editing" + : !compatible || failed + ? "error" + : ready && !usesTextEndpoint + ? "connected" + : "not_connected"; + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +export default function AISettings() { + const form = useSettingsForm({ keys: KEYS }); + const restartKeys = useRestartKeys(); + const textCheck = useCheckAdminSettingsConnection(); + const speechCheck = useCheckAdminSettingsConnection(); + const [textResult, setTextResult] = useState( + undefined, + ); + const [speechResult, setSpeechResult] = useState( + undefined, + ); + const [expandedTile, setExpandedTile] = useState(null); + + if (form.isLoading) { + return ( +
+ + + + + Loading AI Services settings +
+ ); + } + + const value = (key: string, fallback = "") => form.getValue(key) || fallback; + // Legacy `subtitle_ai.*` values stay authoritative until the modern `ai.*` + // key holds something, exactly as the old AI Services tab read them. + const effectiveValue = (key: string, legacyKey: string, fallback: string) => + value(key, value(legacyKey, fallback)); + + const textBaseURL = effectiveValue( + "ai.base_url", + "subtitle_ai.base_url", + "https://api.openai.com", + ); + const chatModel = effectiveValue( + "ai.chat_model", + "subtitle_ai.chat_model", + "gpt-4o-mini", + ); + const asrBaseURL = value("ai.asr_base_url"); + const asrModel = value("ai.asr_model", "whisper-1"); + const textReady = textBaseURL.trim() !== "" && chatModel.trim() !== ""; + const speechUsesTextEndpoint = asrBaseURL.trim() === ""; + const speechCheckable = + (asrBaseURL.trim() !== "" || textBaseURL.trim() !== "") && + asrModel.trim() !== ""; + const speechCompatible = !isChatOnlyGateway( + speechUsesTextEndpoint ? textBaseURL : asrBaseURL, + ); + const speechReady = speechCheckable && speechCompatible; + const subtitleTranslateEnabled = + value("subtitle_ai.enabled", "false") === "true"; + const transcribeEnabled = + value("subtitle_ai.transcribe_enabled", "false") === "true"; + const descriptionEnabled = value("metadata_ai.enabled", "false") === "true"; + const textDirty = TEXT_AI_KEYS.some((key) => form.isDirty(key)); + const speechDirty = SPEECH_ONLY_KEYS.some((key) => form.isDirty(key)); + const advancedChangedCount = AI_ADVANCED_KEYS.filter((key) => + form.isDirty(key), + ).length; + + function setValue(key: string, nextValue: string) { + form.setValue(key, nextValue); + if (TEXT_AI_KEYS.includes(key as (typeof TEXT_AI_KEYS)[number])) { + setTextResult(undefined); + } + if (SPEECH_AI_KEYS.includes(key as (typeof SPEECH_AI_KEYS)[number])) { + setSpeechResult(undefined); + } + } + + async function checkTextConnection() { + const started = Date.now(); + try { + const result = await textCheck.mutateAsync({ + kind: "ai_chat", + body: form.buildConnectionCheckRequest([...TEXT_AI_KEYS]), + }); + setTextResult({ + ok: result.success, + message: result.message, + at: Date.now(), + durationMs: Date.now() - started, + }); + } catch (error) { + setTextResult({ + ok: false, + message: + error instanceof Error + ? error.message + : "Text model connection check failed.", + at: Date.now(), + durationMs: Date.now() - started, + }); + } + } + + async function checkSpeechConnection() { + const started = Date.now(); + try { + const result = await speechCheck.mutateAsync({ + kind: "ai_transcription", + body: form.buildConnectionCheckRequest([...SPEECH_AI_KEYS]), + }); + setSpeechResult({ + ok: result.success, + message: result.message, + at: Date.now(), + durationMs: Date.now() - started, + }); + } catch (error) { + setSpeechResult({ + ok: false, + message: + error instanceof Error + ? error.message + : "Speech-to-text connection check failed.", + at: Date.now(), + durationMs: Date.now() - started, + }); + } + } + + async function save() { + const batchSize = parseStrictInteger(value("subtitle_ai.batch_size", "40")); + const contextLines = parseStrictInteger( + value("subtitle_ai.context_neighbors", "2"), + ); + const chunkSeconds = parseStrictInteger( + value("subtitle_ai.asr_chunk_seconds", "600"), + ); + const quotaJobs = parseStrictInteger( + value("subtitle_ai.transcribe_quota_jobs", "0"), + ); + const maxConcurrent = parseStrictInteger( + effectiveValue( + "ai.max_concurrent_jobs", + "subtitle_ai.max_concurrent_jobs", + "2", + ), + ); + + if (!textReady) { + toast.error("Text AI base URL and chat model are required."); + return; + } + if (maxConcurrent === null || maxConcurrent < 1) { + toast.error("Max concurrent jobs must be a positive whole number."); + return; + } + if (batchSize === null || batchSize < 1) { + toast.error("Subtitle batch size must be a positive whole number."); + return; + } + if (contextLines === null || contextLines < 0) { + toast.error( + "Subtitle context lines must be zero or a positive whole number.", + ); + return; + } + if (chunkSeconds === null || chunkSeconds < 60 || chunkSeconds > 600) { + toast.error( + "Transcription chunk length must be between 60 and 600 seconds.", + ); + return; + } + if (quotaJobs === null || quotaJobs < 0) { + toast.error( + "Transcription limit must be zero or a positive whole number.", + ); + return; + } + await form.save(); + } + + function discard() { + form.discard(); + setTextResult(undefined); + setSpeechResult(undefined); + } + + return ( +
+ + + +
+ + { + setValue("ai.api_key", ""); + setValue("subtitle_ai.api_key", ""); + }} + onTest={() => void checkTextConnection()} + isTesting={textCheck.isPending} + test={textResult} + // A staged edit forces its tile open: the save bar must never + // block on a field the admin cannot see. + expanded={expandedTile === "text" || textDirty} + onExpand={() => setExpandedTile("text")} + onCollapse={() => setExpandedTile(null)} + /> + setValue("ai.asr_api_key", "")} + onTest={() => void checkSpeechConnection()} + isTesting={speechCheck.isPending} + test={speechResult} + expanded={expandedTile === "speech" || speechDirty} + onExpand={() => setExpandedTile("speech")} + onCollapse={() => setExpandedTile(null)} + /> + +
+
+ + +

+ Nothing here runs on a schedule: subtitle work starts when a viewer or + admin asks for a track, and description translation when an admin + queues it or a viewer opens a detail page. +

+ {/* + A feature whose model is not configured only queues jobs that fail at + the provider, so its switch is disabled until the model is ready. One + that is already on stays switchable, so a degraded provider can be + turned off without being fixed first. + */} + setValue("subtitle_ai.enabled", next)} + description="Turns an existing subtitle track into another language, on request." + disabled={!textReady && !subtitleTranslateEnabled} + status={ + textReady ? undefined : ( + + Needs the text model + + ) + } + restartRequired={restartKeys.has("subtitle_ai.enabled")} + /> + setValue("subtitle_ai.transcribe_enabled", next)} + description="Writes timed subtitles from the audio track, on request." + disabled={!speechReady && !transcribeEnabled} + status={ + speechReady ? undefined : ( + + Needs speech-to-text + + ) + } + restartRequired={restartKeys.has("subtitle_ai.transcribe_enabled")} + /> + setValue("metadata_ai.enabled", next)} + description="Translates overviews and taglines for the items an admin or viewer asks for." + disabled={!textReady && !descriptionEnabled} + status={ + textReady ? undefined : ( + + Needs the text model + + ) + } + restartRequired={restartKeys.has("metadata_ai.enabled")} + /> + setValue("metadata_ai.on_view", next)} + disabled={!descriptionEnabled} + options={[ + { value: "off", label: "Off" }, + { value: "button", label: "Translate button on detail pages" }, + { value: "auto", label: "Automatic on view" }, + ]} + description={ + descriptionEnabled + ? undefined + : "Inactive until Translate descriptions is on." + } + restartRequired={restartKeys.has("metadata_ai.on_view")} + /> + 0} + > + + setValue("ai.max_concurrent_jobs", next)} + description="One budget shared by every AI job on the server." + restartRequired={restartKeys.has("ai.max_concurrent_jobs")} + /> + setValue("subtitle_ai.batch_size", next)} + restartRequired={restartKeys.has("subtitle_ai.batch_size")} + /> + + setValue("subtitle_ai.context_neighbors", next) + } + restartRequired={restartKeys.has("subtitle_ai.context_neighbors")} + /> + + setValue("subtitle_ai.asr_chunk_seconds", next) + } + description="Between 60 and 600." + restartRequired={restartKeys.has("subtitle_ai.asr_chunk_seconds")} + /> + + + + setValue("subtitle_ai.transcribe_quota_jobs", next) + } + fallbackValue="10" + hint="Every profile on the account draws from this one allowance." + restartRequired={restartKeys.has( + "subtitle_ai.transcribe_quota_jobs", + )} + /> + + setValue("subtitle_ai.transcribe_quota_period", next) + } + options={QUOTA_PERIODS.map((period) => ({ + value: period, + label: `Per ${period} (rolling ${QUOTA_PERIOD_WINDOW_LABELS[period]})`, + }))} + description="Rolling window for the transcription allowance above." + restartRequired={restartKeys.has( + "subtitle_ai.transcribe_quota_period", + )} + /> + + +
+ +

+ Recommendation embeddings use their own models. + + Open Recommendations + +

+ + void save()} + onDiscard={discard} + isSaving={form.isSaving} + /> +
+ ); +} diff --git a/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx b/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx index 43e2d81d3..666f32b99 100644 --- a/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx +++ b/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx @@ -1,27 +1,57 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { renderToStaticMarkup } from "react-dom/server"; -import { MemoryRouter } from "react-router"; +import { createMemoryRouter, RouterProvider } from "react-router"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SettingsOverviewModel } from "@/hooks/admin/useSettingsOverview"; +import { + ADMIN_SETTINGS_NAV, + LEGACY_ADMIN_SETTINGS_PAGE_ALIASES, +} from "@/lib/adminSettingsSearch"; + import AdminSettingsLayout from "./AdminSettingsLayout"; const mocks = vi.hoisted(() => ({ useAdminServerStatus: vi.fn(), + useSettingsOverview: vi.fn(), + // How many staged edits the open page claims; drives the shell's + // unsaved-changes guard. + dirtyCount: 0, })); -// The layout only needs the active tab's component to render; a loading form -// keeps every settings page on its skeleton state so no other hooks fire. -vi.mock("@/hooks/useSettingsForm", () => ({ - useSettingsForm: () => ({ - isLoading: true, - dirtyCount: 0, - getValue: () => "", - sensitiveConfigured: [], - sensitiveManagedByEnv: [], - }), -})); +// The layout only needs the active page's component to render; a loading form +// keeps every settings page on its skeleton state so no other hooks fire. The +// dirty registration is the one real behavior kept, because the shell's +// navigation guard reads it. +vi.mock("@/hooks/useSettingsForm", async () => { + const { useReportUnsavedChanges } = await import("@/hooks/useUnsavedChanges"); + + return { + useSettingsForm: () => { + useReportUnsavedChanges(mocks.dirtyCount > 0); + + return { + isLoading: true, + getValue: () => "", + setValue: () => {}, + resetValue: () => {}, + dirtyCount: mocks.dirtyCount, + dirtyKeys: [], + isDirty: () => false, + save: () => {}, + discard: () => {}, + isSaving: false, + restartRequired: false, + sensitiveConfigured: [], + sensitiveManagedByEnv: [], + sensitiveStatusReady: false, + sensitiveStatusError: null, + buildConnectionCheckRequest: () => ({}), + }; + }, + }; +}); vi.mock("@/hooks/queries/admin/settings", async (importOriginal) => ({ ...(await importOriginal()), @@ -29,256 +59,295 @@ vi.mock("@/hooks/queries/admin/settings", async (importOriginal) => ({ mocks.useAdminServerStatus(...args), })); +vi.mock("@/hooks/admin/useSettingsOverview", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/hooks/admin/useSettingsOverview") + >()), + useSettingsOverview: () => mocks.useSettingsOverview(), +})); + +function overviewModel(): SettingsOverviewModel { + return { + isLoading: false, + tiles: [], + cards: [], + }; +} + beforeEach(() => { mocks.useAdminServerStatus.mockReturnValue({ data: { restart_required: false }, }); + mocks.useSettingsOverview.mockReturnValue(overviewModel()); + mocks.dirtyCount = 0; }); afterEach(() => { vi.unstubAllGlobals(); }); -function renderLayout(search = "") { +// A data router, like the app itself mounts: the shell blocks navigation with +// `useBlocker`, which the declarative `` cannot serve. The second +// route stands in for the rest of the admin area (the sidebar's targets). +function renderInteractiveLayout(suffix = "") { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); - - return renderToStaticMarkup( - - - - - , + const router = createMemoryRouter( + [ + { path: "/admin/settings/*", element: }, + { path: "/admin/users", element:

Admin users

}, + ], + { initialEntries: [`/admin/settings${suffix}`] }, ); -} - -function renderInteractiveLayout(search = "") { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return render( - - - - - , - ); + return { + router, + ...render( + + + , + ), + }; } describe("AdminSettingsLayout", () => { - it("renders the grouped navigation sections", () => { - const markup = renderLayout(); - - for (const group of ["Server", "Media", "Connections", "Data"]) { - expect(markup).toContain(`>${group}<`); - } - }); - - it("names each settings group exactly once", () => { + it("lands on the overview at the settings index", () => { renderInteractiveLayout(); - // The category jump bar used to repeat every group name and count directly - // above the headings that already carry them. expect( - screen.queryByRole("navigation", { - name: "Admin settings sections categories", - }), + screen.getByRole("heading", { level: 1, name: "Settings" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: "All settings" }), ).not.toBeInTheDocument(); - for (const group of ["Server", "Media", "Connections", "Data"]) { - expect(screen.getAllByRole("heading", { name: group })).toHaveLength(1); - expect( - screen.queryByRole("link", { - name: new RegExp(`^${group}, \\d+ settings`), - }), - ).toBeNull(); - } }); - it("uses one desktop grid and card geometry for every settings group", () => { - const markup = renderLayout(); + it("renders a settings category with the page rail beside it", () => { + vi.stubGlobal("scrollTo", vi.fn()); + renderInteractiveLayout("/general"); - expect(markup.match(/2xl:grid-cols-4/g)).toHaveLength(4); - expect(markup).not.toContain("2xl:grid-cols-3"); - expect(markup.match(/lg:h-28/g)).toHaveLength(21); - expect(markup.match(/lg:line-clamp-3/g)).toHaveLength(21); - }); + expect( + screen.getByRole("region", { name: "General settings" }), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "All settings" })).toHaveAttribute( + "href", + "/admin/settings", + ); - it("renders every settings tab", () => { - const markup = renderLayout(); - - for (const label of [ - "About", - "General", - "Branding", - "Theming", - "Card Overlays", - "Scanner & Matcher", - "Search", - "Intro Markers", - "Subtitles", - "AI Services", - "Playback", - "Downloads", - "Watch Providers", - "Integrations", - "Email", - "Notifications", - "Compatibility Proxies", - "Rate Limiting", - "Database", - "Storage", - "Log Retention", - ]) { - expect(markup).toContain(label); + // The rail lists every settings page and marks the open one. + const rail = screen.getByRole("navigation", { name: "Settings pages" }); + for (const item of ADMIN_SETTINGS_NAV) { + const link = within(rail).getByRole("link", { name: item.label }); + expect(link).toHaveAttribute("href", `/admin/settings/${item.id}`); } + expect(within(rail).getByRole("link", { name: "General" })).toHaveAttribute( + "aria-current", + "page", + ); }); - it("renders the settings index at the root and preserves tab deep links", () => { - renderInteractiveLayout(); + it("mounts every settings page at its own route", () => { + vi.stubGlobal("scrollTo", vi.fn()); - expect( - screen.getByRole("link", { name: /General.*Authentication/ }), - ).toHaveAttribute("href", "/admin/settings?tab=general"); - expect( - screen.queryByRole("link", { name: "All settings" }), - ).not.toBeInTheDocument(); + for (const item of ADMIN_SETTINGS_NAV) { + const { unmount } = renderInteractiveLayout(`/${item.id}`); - const detail = renderLayout("?tab=general"); - expect(detail).toContain('aria-current="page"'); - expect(detail).toContain('href="/admin/settings"'); + expect( + screen.getByRole("region", { name: `${item.label} settings` }), + ).toBeInTheDocument(); + unmount(); + } }); - it("focuses the detail heading and resets scroll when an overview link opens", async () => { + it("focuses the settings page and resets document scroll when it opens", async () => { const scrollTo = vi.fn(); vi.stubGlobal("scrollTo", scrollTo); - renderInteractiveLayout(); + renderInteractiveLayout("/general"); - await userEvent.click( - screen.getByRole("link", { name: /Database.*Postgres/ }), - ); - - const detailRegion = await screen.findByRole("region", { - name: "Database settings", + const region = await screen.findByRole("region", { + name: "General settings", }); expect(scrollTo).toHaveBeenCalledWith(0, 0); - expect(detailRegion).toHaveFocus(); + expect(region).toHaveFocus(); }); - it("adds a mobile detail heading when the settings component has none", () => { + it("leaves the restart prompt to the admin shell", () => { vi.stubGlobal("scrollTo", vi.fn()); + mocks.useAdminServerStatus.mockReturnValue({ + data: { restart_required: true }, + }); - renderInteractiveLayout("?tab=branding"); + renderInteractiveLayout("/general"); - expect( - screen.getByRole("heading", { name: "Branding", level: 2 }), - ).toHaveFocus(); + // AdminLayout renders one banner above every admin page, so a restart owed + // while settings is open must not add a second one here. + expect(screen.queryByText("Restart required")).not.toBeInTheDocument(); }); - it("resets the scrolling detail pane when switching admin tabs", async () => { + it("redirects legacy query-string tabs to their canonical pages", async () => { vi.stubGlobal("scrollTo", vi.fn()); - renderInteractiveLayout("?tab=general"); - const generalRegion = screen.getByRole("region", { - name: "General settings", - }); - generalRegion.scrollTop = 400; - - await userEvent.click(screen.getByRole("button", { name: "Database" })); + for (const [legacy, current] of Object.entries( + LEGACY_ADMIN_SETTINGS_PAGE_ALIASES, + )) { + const label = ADMIN_SETTINGS_NAV.find( + (item) => item.id === current, + )?.label; + expect(label).toBeDefined(); - const databaseRegion = await screen.findByRole("region", { - name: "Database settings", - }); - expect(databaseRegion.scrollTop).toBe(0); - expect(databaseRegion).toHaveFocus(); + const { unmount } = renderInteractiveLayout(`?tab=${legacy}`); + expect( + await screen.findByRole("region", { name: `${label} settings` }), + ).toBeInTheDocument(); + unmount(); + } }); - it("surfaces durable restart-required state above the active tab", () => { - mocks.useAdminServerStatus.mockReturnValue({ - data: { restart_required: true }, - }); + it("redirects a retired page route to the page that absorbed it", async () => { + vi.stubGlobal("scrollTo", vi.fn()); + renderInteractiveLayout("/jellyfin"); - const markup = renderLayout(); + expect( + await screen.findByRole("region", { name: "Compatibility settings" }), + ).toBeInTheDocument(); + }); - expect(markup).toContain( - "Server restart required for saved settings to take effect.", - ); + it("redirects an unknown settings page to the overview", async () => { + vi.stubGlobal("scrollTo", vi.fn()); + renderInteractiveLayout("/not-a-page"); + + expect( + await screen.findByRole("heading", { level: 1, name: "Settings" }), + ).toBeInTheDocument(); }); - it("resolves the legacy jellyfin tab alias to Compatibility Proxies", () => { - const withAlias = renderLayout("?tab=jellyfin"); - const direct = renderLayout("?tab=compatibility-proxies"); + it("keeps the rail filter inside the rail, clear of the fixed admin header controls", () => { + vi.stubGlobal("scrollTo", vi.fn()); + renderInteractiveLayout("/general"); + + const box = screen.getByRole("searchbox", { name: "Search settings" }); + const rail = screen.getByRole("navigation", { name: "Settings pages" }); + const aside = rail.closest("aside"); - expect(withAlias).toBe(direct); + expect(aside).not.toBeNull(); + expect(aside).toContainElement(box); + // AdminLayout floats its own Search ⌘K control over the top-right corner, + // so the back-link row has to stay free of a second search input. + expect( + screen.getByRole("link", { name: "All settings" }).parentElement, + ).not.toContainElement(box); }); - it("filters admin settings sections from the search box", async () => { - renderInteractiveLayout(); + it("filters the page rail from the settings search box", async () => { + vi.stubGlobal("scrollTo", vi.fn()); + renderInteractiveLayout("/general"); - await userEvent.type( - screen.getByRole("searchbox", { name: "Search settings" }), - "redis", - ); + const box = screen.getByRole("searchbox", { name: "Search settings" }); + await userEvent.type(box, "transcode"); - expect(screen.getAllByRole("link", { name: /Database/ })).toHaveLength(1); + const rail = screen.getByRole("navigation", { name: "Settings pages" }); expect( - screen.queryByRole("link", { name: /Playback/ }), + within(rail).getByRole("link", { name: "Playback" }), + ).toBeInTheDocument(); + expect( + within(rail).queryByRole("link", { name: "General" }), ).not.toBeInTheDocument(); - expect(screen.getByText("1 match")).toBeInTheDocument(); + + await userEvent.clear(box); + expect( + within(rail).getByRole("link", { name: "General" }), + ).toBeInTheDocument(); }); - it("matches individual admin setting labels", async () => { - renderInteractiveLayout(); + it("keeps `ai` pointing at the AI Services page rather than an alias", () => { + vi.stubGlobal("scrollTo", vi.fn()); + renderInteractiveLayout("/ai"); - await userEvent.type( - screen.getByRole("searchbox", { name: "Search settings" }), - "pool max open", - ); + expect( + screen.getByRole("region", { name: "AI Services settings" }), + ).toBeInTheDocument(); + }); +}); + +describe("AdminSettingsLayout unsaved-changes guard", () => { + // Radix marks the page behind an open modal inert and aria-hidden, so while + // the prompt is up the page under it is only reachable by text. + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + beforeEach(() => { + vi.stubGlobal("scrollTo", vi.fn()); + }); + + it("lets a clean page move through the rail untouched", async () => { + renderInteractiveLayout("/general"); + + const rail = screen.getByRole("navigation", { name: "Settings pages" }); + await user.click(within(rail).getByRole("link", { name: "Playback" })); - expect(screen.getAllByRole("link", { name: /Database/ })).toHaveLength(1); expect( - screen.queryByRole("link", { name: /General/ }), - ).not.toBeInTheDocument(); + screen.getByRole("region", { name: "Playback settings" }), + ).toBeInTheDocument(); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); }); - it("focuses admin settings search with Cmd+K", () => { - renderInteractiveLayout(); + it("holds a rail navigation until staged edits are discarded", async () => { + mocks.dirtyCount = 1; + const { router } = renderInteractiveLayout("/general"); - const searchBox = screen.getByRole("searchbox", { - name: "Search settings", - }); - fireEvent.keyDown(document, { key: "k", metaKey: true }); + const rail = screen.getByRole("navigation", { name: "Settings pages" }); + await user.click(within(rail).getByRole("link", { name: "Playback" })); + + expect( + await screen.findByRole("alertdialog", { + name: "Discard unsaved changes?", + }), + ).toBeInTheDocument(); + expect(router.state.location.pathname).toBe("/admin/settings/general"); - expect(searchBox).toHaveFocus(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect( + screen.getByRole("region", { name: "General settings" }), + ).toBeInTheDocument(); + + await user.click(within(rail).getByRole("link", { name: "Playback" })); + await user.click(await screen.findByRole("button", { name: "Discard" })); + + expect( + await screen.findByRole("region", { name: "Playback settings" }), + ).toBeInTheDocument(); }); - it("focuses admin settings search with Ctrl+K", () => { - renderInteractiveLayout(); + it("guards the back link out of the settings shell", async () => { + mocks.dirtyCount = 2; + renderInteractiveLayout("/general"); - const searchBox = screen.getByRole("searchbox", { - name: "Search settings", - }); - fireEvent.keyDown(document, { key: "k", ctrlKey: true }); + await user.click(screen.getByRole("link", { name: "All settings" })); + + expect(await screen.findByRole("alertdialog")).toBeInTheDocument(); - expect(searchBox).toHaveFocus(); + await user.click(screen.getByRole("button", { name: "Discard" })); + + expect( + await screen.findByRole("heading", { level: 1, name: "Settings" }), + ).toBeInTheDocument(); }); - it("does not consume Cmd+K when the admin detail search is hidden", () => { - vi.stubGlobal( - "matchMedia", - vi.fn(() => ({ matches: false })), - ); - vi.stubGlobal("scrollTo", vi.fn()); - renderInteractiveLayout("?tab=general"); + it("guards a navigation that leaves the settings area entirely", async () => { + mocks.dirtyCount = 1; + const { router } = renderInteractiveLayout("/general"); - const event = new KeyboardEvent("keydown", { - key: "k", - metaKey: true, - cancelable: true, + // Stands in for the admin sidebar and for browser back: both reach the + // router the same way. + await act(async () => { + await router.navigate("/admin/users"); }); - expect(document.dispatchEvent(event)).toBe(true); - expect(event.defaultPrevented).toBe(false); + expect(await screen.findByRole("alertdialog")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect( + screen.getByRole("region", { name: "General settings" }), + ).toBeInTheDocument(); }); }); diff --git a/web/src/pages/admin-settings/AdminSettingsLayout.tsx b/web/src/pages/admin-settings/AdminSettingsLayout.tsx index 0c48cf451..09b789053 100644 --- a/web/src/pages/admin-settings/AdminSettingsLayout.tsx +++ b/web/src/pages/admin-settings/AdminSettingsLayout.tsx @@ -5,78 +5,56 @@ import { useState, type ComponentType, } from "react"; -import { AlertTriangle, ChevronLeft } from "lucide-react"; -import { Link, useSearchParams } from "react-router"; +import { ChevronLeft } from "lucide-react"; +import { Link, Navigate, useParams, useSearchParams } from "react-router"; -import { SideNavItem, SideNavSection } from "@/components/SideNav"; -import { SettingsOverviewNav } from "@/components/settings/SettingsOverviewNav"; -import { SettingsSearchInput } from "@/components/settings/SettingsSearchInput"; -import { - countSettingsSearchItems, - filterSettingsSearchGroups, -} from "@/components/settings/settingsSearch"; import { ADMIN_SETTINGS_GROUPS, ADMIN_SETTINGS_NAV, + resolveAdminSettingsPageID, type AdminSettingsSearchItem, } from "@/lib/adminSettingsSearch"; -import { cn } from "@/lib/utils"; -import { useAdminServerStatus } from "@/hooks/queries/admin/settings"; +import { + countSettingsSearchItems, + filterSettingsSearchGroups, +} from "@/components/settings/settingsSearch"; +import { SettingsSearchInput } from "@/components/settings/SettingsSearchInput"; +import { UnsavedChangesGuard } from "@/components/UnsavedChangesGuard"; +import { settingsPageHref } from "@/hooks/admin/useSettingsOverview"; +import { SettingsPageRail } from "@/components/settings/SettingsPageRail"; -import EmailSettings from "./EmailSettings"; -import NotificationsAdminSettings from "./NotificationsAdminSettings"; -import AboutSettings from "./AboutSettings"; import GeneralSettings from "./GeneralSettings"; +import AppearanceSettings from "./AppearanceSettings"; +import SecurityAccessSettings from "./SecurityAccessSettings"; +import LibraryMetadataSettings from "./LibraryMetadataSettings"; import PlaybackSettings from "./PlaybackSettings"; -import ScannerSettings from "./ScannerSettings"; -import SearchSettings from "./SearchSettings"; -import IntroSettings from "./IntroSettings"; -import SubtitlesSettings from "./SubtitlesSettings"; -import AIServicesSettings from "./AIServicesSettings"; -import RateLimitSettings from "./RateLimitSettings"; -import WatchProvidersSettings from "./WatchProvidersSettings"; -import IntegrationsSettings from "./IntegrationsSettings"; +import DownloadsSettings from "./DownloadsSettings"; +import ProvidersSettings from "./ProvidersSettings"; +import WatchSyncSettings from "./WatchSyncSettings"; +import AISettings from "./AISettings"; +import NotificationsAdminSettings from "./NotificationsAdminSettings"; import CompatibilityProxiesSettings from "./CompatibilityProxiesSettings"; -import DatabaseSettings from "./DatabaseSettings"; -import StorageSettings from "./StorageSettings"; -import DownloadSettings from "./DownloadSettings"; -import LogRetentionSettings from "./LogRetentionSettings"; -import ThemeSettings from "./ThemeSettings"; -import BrandingSettings from "./BrandingSettings"; -import OverlaySettings from "./OverlaySettings"; -import { RestartServerButton } from "./RestartServerButton"; +import InfrastructureSettings from "./InfrastructureSettings"; +import SettingsOverview from "./SettingsOverview"; +import "@/styles/admin-settings.css"; interface SettingsNav extends AdminSettingsSearchItem { component: ComponentType; } -interface SettingsNavGroup { - label: string; - items: SettingsNav[]; -} - const SETTINGS_COMPONENTS: Record = { - about: AboutSettings, general: GeneralSettings, - branding: BrandingSettings, - theming: ThemeSettings, - overlays: OverlaySettings, - scanner: ScannerSettings, - search: SearchSettings, - intro: IntroSettings, - subtitles: SubtitlesSettings, - ai: AIServicesSettings, + appearance: AppearanceSettings, + security: SecurityAccessSettings, + library: LibraryMetadataSettings, playback: PlaybackSettings, - downloads: DownloadSettings, - "watch-providers": WatchProvidersSettings, - integrations: IntegrationsSettings, - email: EmailSettings, + downloads: DownloadsSettings, + providers: ProvidersSettings, + "watch-sync": WatchSyncSettings, + ai: AISettings, notifications: NotificationsAdminSettings, - "compatibility-proxies": CompatibilityProxiesSettings, - "rate-limiting": RateLimitSettings, - database: DatabaseSettings, - storage: StorageSettings, - "log-retention": LogRetentionSettings, + compatibility: CompatibilityProxiesSettings, + infrastructure: InfrastructureSettings, }; function settingsComponent(id: string) { @@ -87,178 +65,110 @@ function settingsComponent(id: string) { return component; } -const SETTINGS_GROUPS: SettingsNavGroup[] = ADMIN_SETTINGS_GROUPS.map( - (group) => ({ - ...group, - items: group.items.map((item) => ({ - ...item, - component: settingsComponent(item.id), - })), - }), -); - const SETTINGS_NAV: SettingsNav[] = ADMIN_SETTINGS_NAV.map((item) => ({ ...item, component: settingsComponent(item.id), })); -const SHELL_HEADING_SETTINGS = new Set(["branding", "theming"]); - export default function AdminSettingsLayout() { - const [searchParams, setSearchParams] = useSearchParams(); - const [settingsSearch, setSettingsSearch] = useState(""); + const params = useParams(); + const [searchParams] = useSearchParams(); const activeContentRef = useRef(null); - const activeHeadingRef = useRef(null); - const { data: serverStatus } = useAdminServerStatus(); - const rawActiveId = searchParams.get("tab"); - const activeId = - rawActiveId === "jellyfin" ? "compatibility-proxies" : rawActiveId; - const filteredSettingsGroups = useMemo( - () => filterSettingsSearchGroups(SETTINGS_GROUPS, settingsSearch), + const [settingsSearch, setSettingsSearch] = useState(""); + const filteredGroups = useMemo( + () => filterSettingsSearchGroups(ADMIN_SETTINGS_GROUPS, settingsSearch), [settingsSearch], ); - const overviewGroups = useMemo( - () => - filteredSettingsGroups.map((group) => ({ - ...group, - items: group.items.map((item) => ({ - id: item.id, - label: item.label, - description: item.description, - icon: item.icon, - href: `/admin/settings?tab=${encodeURIComponent(item.id)}`, - })), - })), - [filteredSettingsGroups], - ); - const filteredSettingsCount = countSettingsSearchItems( - filteredSettingsGroups, + const filteredItems = useMemo( + () => filteredGroups.flatMap((group) => group.items), + [filteredGroups], ); + const rawPageId = params["*"]?.replace(/^\/+|\/+$/g, "") || null; + const legacyTabId = searchParams.get("tab"); + const requestedId = rawPageId ?? legacyTabId; + const activeId = resolveAdminSettingsPageID(requestedId); - function setActiveId(id: string) { - setSearchParams({ tab: id }, { replace: true }); - } const active = activeId ? SETTINGS_NAV.find((item) => item.id === activeId) : undefined; const ActiveComponent = active?.component; useEffect(() => { - if (!active) return; + if (!activeId) return; window.scrollTo(0, 0); if (activeContentRef.current) { activeContentRef.current.scrollTop = 0; } - (activeHeadingRef.current ?? activeContentRef.current)?.focus({ - preventScroll: true, - }); - }, [active]); + activeContentRef.current?.focus({ preventScroll: true }); + }, [activeId]); - return ( -
- {active ? ( - -
); diff --git a/web/src/pages/admin-settings/AppearanceSettings.test.tsx b/web/src/pages/admin-settings/AppearanceSettings.test.tsx new file mode 100644 index 000000000..f7cb46009 --- /dev/null +++ b/web/src/pages/admin-settings/AppearanceSettings.test.tsx @@ -0,0 +1,271 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const useSettingsFormMock = vi.fn(); + +vi.mock("@/hooks/useSettingsForm", () => ({ + useSettingsForm: (...args: unknown[]) => useSettingsFormMock(...args), +})); + +vi.mock("@/hooks/useRestartKeys", () => ({ + useRestartKeys: () => new Set(), +})); + +vi.mock("@/hooks/useBranding", () => ({ + useBranding: () => ({ + storageAvailable: true, + wordmarkUrl: null, + markUrl: null, + faviconUrl: null, + loginBgUrl: null, + }), +})); + +vi.mock("@/components/admin/BrandingAssetField", () => ({ + BrandingAssetField: ({ label }: { label: string }) =>
{label}
, +})); + +vi.mock("@/components/theme/TokenEditor", () => ({ + TokenEditor: ({ + onSetVar, + }: { + onSetVar: (token: "primary", value: string) => void; + }) => ( + + ), +})); + +vi.mock("@/components/theme/RawCssEditor", () => ({ + RawCssEditor: ({ + value, + onChange, + }: { + value: string; + onChange: (css: string) => void; + }) => ( +