docs(saved-filters,dashboard,system,series): Beam WO3 batch 5 — 15 endpoints + dedupe

Beam drafted 15 frontmatter'd docs (saved-filters CRUD, dashboard
sections/preferences/restore, system config/settings, series list + books);
Claude verified shapes against handlers and pinned the registry:
- saved-filters: SavedFilterResponse pinned by type (RFC3339 timestamps here,
  unlike most of the API); create/update share CreateSavedFilterRequest
  (resource_type oneof media-items|collections|devices)
- dashboard: preferences GET pin {hidden_collections, collection_order,
  items_per_section}; PUT returns full UserDashboardPreferences row; sections
  wrapper {sections}
- system: GET+PUT /api/system/config are admin-defined key/value maps — new
  AnyJSONObject sentinel (gate shape-checks without key comparison; request
  side too). UpdateSystemConfiguration returns {status, message} — verified,
  not guessed. GET /api/system/settings returns []SettingEntry.
- series: {series[{name, library_id, book_count, total_in_series,
  cover_paths, last_entry_at}], total, limit, offset}; books by
  ?name=&library_id= with family-lens 403

Dedupe: deleted dashboard.md, system/config.md, system/settings.md — each
covered the same endpoints as Beam's per-endpoint files (content merged by
the gate-verified shapes; old prose recoverable from git).

Beam clarifications confirmed: scanner routes are WO5 (docs exist);
progress/books-match are WO5.
This commit is contained in:
John O'Keefe committed 2026-10-24 15:29:24 -04:00
1 parent 7471ca99e8
commit fa00d6d3cb
20 files changed
+714 -416

No files matched your search

-143
View File
@@ -1,143 +0,0 @@
# Dashboard API
## Get Dashboard Sections
Retrieve all dashboard sections for a specific library, including system collections and user collections.
**Endpoint**: `GET /api/dashboard/sections`
**Authentication**: Required (Bearer token)
### Query Parameters
| Parameter | Type | Required | Description |
| ---------- | ------ | -------- | ----------------------------------------- |
| library_id | string | Yes | Library UUID to fetch sections for |
| limit | number | No | Items per section (default: 20, max: 100) |
### Response
Returns array of sections in user's customized order (respects `collection_order` and `hidden_collections` preferences).
**Section Types**:
- `is_system: true`: System collections (4 pre-seeded defaults)
- `is_system: false`: User-created collections with `show_on_dashboard: true`
**System Collections**:
| ID | Title | Icon | Description |
|-----------------|------------------|------|--------------------------------------------------|
| continue-reading| Continue Reading | 📖 | Books with progress > 0% and < 100% |
| recently-added | Recently Added | 🆕 | Newest items in library |
| recently-read | Recently Read | ✅ | Books with progress = 100% |
| not-started | Not Started | 📕 | Books with no reading progress |
### Example Response
```json
{
"sections": [
{
"id": "continue-reading",
"is_system": true,
"title": "Continue Reading",
"description": "Books you're currently reading (0 < progress < 1)",
"icon": "📖",
"items": [
{
"media_item_id": "uuid-here",
"title": "Book Title",
"author": "Author Name",
"cover_image_path": "/path/to/cover.jpg"
}
],
"view_all_url": "/section/continue-reading",
"priority": 1
},
{
"id": "collection-uuid",
"is_system": false,
"title": "My Favorites",
"description": "My favorite books",
"icon": "⭐",
"items": [],
"view_all_url": "",
"priority": 100
}
]
}
```
### User Preferences
The endpoint respects user's dashboard preferences:
- **`collection_order`**: Sections returned in user's custom order
- **`hidden_collections`**: Hidden collections excluded from response
- **`items_per_section`**: Default limit from user preferences (overridden by `?limit=` query param)
## Update Dashboard Preferences
Save or update user dashboard preferences for a specific library.
**Endpoint**: `PUT /api/dashboard/preferences`
**Authentication**: Required (Bearer token)
### Request Body
```json
{
"library_id": "uuid",
"hidden_collections": ["not-started"],
"collection_order": ["recently-added", "continue-reading", "recently-read"],
"items_per_section": 20
}
```
### Response
Returns updated preferences object.
## Restore System Collection
Reset a system collection to its default state (removes user customizations).
**Endpoint**: `POST /api/dashboard/restore-system-collection`
**Authentication**: Required (Bearer token)
### Request Body
```json
{
"collection_name": "continue-reading"
}
```
Valid `collection_name` values:
- `continue-reading`
- `recently-added`
- `recently-read`
- `not-started`
### Response
```json
{
"message": "System collection restored to defaults"
}
```
### Error Responses
| Status | Description |
| ------ | ---------------------------- |
| 400 | Missing library_id |
| 400 | Invalid library_id |
| 400 | Invalid collection_name |
| 401 | Unauthorized |
| 500 | Failed to load sections |
| 500 | Failed to save preferences |
| 500 | Failed to restore collection |
@@ -0,0 +1,34 @@
---
method: GET
path: /api/dashboard/preferences
auth: jwt
area: dashboard
---
<!-- source: internal/handlers/dashboard.go:285 DashboardHandler.GetPreferences -->
# Get Dashboard Preferences
Returns the signed-in user's dashboard preferences for a library (or the
global scope when `library_id` is omitted).
## Example response
```json example-response
{
"hidden_collections": [],
"collection_order": [],
"items_per_section": 20
}
```
## Query parameters
| Param | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| library_id | uuid | No | Library scope; omitted = global |
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "invalid library_id"}` | malformed library_id |
@@ -0,0 +1,49 @@
---
method: GET
path: /api/dashboard/sections
auth: jwt
area: dashboard
---
<!-- source: internal/handlers/dashboard.go:28 DashboardHandler.GetSections -->
# Get Dashboard Sections
Returns all dashboard sections for a library, in the user's customized order.
Respects `collection_order` and `hidden_collections` preferences. `library_id`
is optional; when omitted the global (all-libraries) scope is used.
## Example response
```json example-response
{
"sections": [
{
"id": "continue-reading",
"collection_id": "",
"is_system": true,
"title": "Continue Reading",
"description": "Books you're currently reading (0 < progress < 1)",
"icon": "📖",
"items": [],
"view_all_url": "",
"priority": 1,
"is_playlist": false,
"complete": false
}
]
}
```
## Query parameters
| Param | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| library_id | uuid | No | Library to scope sections to |
| limit | int | No | Items per section (1-100, default from preferences) |
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "invalid library_id"}` | malformed library_id |
| 500 | `{"error": "Failed to load dashboard sections"}` | unexpected server error |
@@ -0,0 +1,42 @@
---
method: POST
path: /api/dashboard/restore-system-collection
auth: jwt
area: dashboard
---
<!-- source: internal/handlers/dashboard.go:132 DashboardHandler.RestoreSystemCollection -->
# Restore System Collection
Resets a system collection to its default state, removing user customizations.
## Example request
```json example-request
{
"collection_name": "Continue Reading",
"reset_type": "full"
}
```
## Example response
```json example-response
{
"message": "System collection restored to defaults"
}
```
## Fields
| Field | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| collection_name | string | Yes | One of `Continue Reading`, `Recently Added`, `Recently Read`, `Not Started`, `Continue Series` |
| reset_type | string | No | `full` (default) or `keep_books` |
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "Invalid request"}` / `{"error": "collection_name required"}` / `{"error": "Invalid system collection name"}` / `{"error": "reset_type must be 'full' or 'keep_books'"}` | malformed body or invalid values |
| 500 | `{"error": "Failed to restore system collection"}` | unexpected server error |
@@ -0,0 +1,53 @@
---
method: PUT
path: /api/dashboard/preferences
auth: jwt
area: dashboard
---
<!-- source: internal/handlers/dashboard.go:83 DashboardHandler.UpdatePreferences -->
# Update Dashboard Preferences
Saves or updates the signed-in user's dashboard preferences for a library.
## Example request
```json example-request
{
"library_id": "550e8400-e29b-41d4-a716-446655440000",
"hidden_collections": ["not-started"],
"collection_order": ["recently-added", "continue-reading", "recently-read"],
"items_per_section": 20
}
```
## Example response
```json example-response
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"library_id": "550e8400-e29b-41d4-a716-446655440000",
"hidden_collections": ["not-started"],
"collection_order": ["recently-added", "continue-reading", "recently-read"],
"items_per_section": 20,
"created_at": "2026-10-24T13:57:01Z",
"updated_at": "2026-10-24T13:57:01Z"
}
```
## Fields
| Field | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| library_id | string (uuid) | No | `null`/absent = global scope; empty string is rejected |
| hidden_collections | array | No | Collection ids to hide |
| collection_order | array | No | Ordered list of collection ids |
| items_per_section | int | No | Items per section |
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "Invalid request"}` / `{"error": "invalid library_id"}` | malformed body or library_id |
| 500 | `{"error": "Failed to save preferences"}` | unexpected server error |
@@ -0,0 +1,50 @@
---
method: POST
path: /api/saved-filters
auth: jwt
area: saved-filters
---
<!-- source: internal/handlers/filters.go:131 FiltersHandler.CreateSavedFilter -->
# Create Saved Filter
Creates a new saved filter for the signed-in user.
## Example request
```json example-request
{
"name": "My Filter",
"resource_type": "media-items",
"filters": {}
}
```
## Example response
```json example-response
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "My Filter",
"resource_type": "media-items",
"filters": {},
"created_at": "2026-10-24T13:57:01Z",
"updated_at": "2026-10-24T13:57:01Z"
}
```
## Fields
| Field | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| name | string | Yes | 1-100 chars |
| resource_type | string | Yes | One of `media-items`, `collections`, `devices` |
| filters | object | Yes | Map of filter key/value pairs |
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "invalid request body"}` / `{"error": "name and resource_type are required"}` | malformed body or missing fields |
| 409 | `{"error": "filter with name '...' already exists for this resource type"}` | duplicate name for that resource type |
| 500 | `{"error": "failed to create filter"}` | unexpected server error |
@@ -0,0 +1,21 @@
---
method: DELETE
path: /api/saved-filters/:id
auth: jwt
area: saved-filters
---
<!-- source: internal/handlers/filters.go:178 FiltersHandler.DeleteSavedFilter -->
# Delete Saved Filter
Deletes a saved filter owned by the signed-in user.
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "invalid filter ID"}` | malformed id |
| 404 | `{"error": "filter not found"}` | no such filter (or not owned) |
| 500 | `{"error": "failed to delete filter"}` | unexpected server error |
Returns `204 No Content` on success.
@@ -0,0 +1,32 @@
---
method: GET
path: /api/saved-filters/:id
auth: jwt
area: saved-filters
---
<!-- source: internal/handlers/filters.go:91 FiltersHandler.GetSavedFilterByID -->
# Get Saved Filter
Returns one saved filter owned by the signed-in user.
## Example response
```json example-response
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "My Filter",
"resource_type": "media-items",
"filters": {},
"created_at": "2026-10-24T13:57:01Z",
"updated_at": "2026-10-24T13:57:01Z"
}
```
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "invalid filter ID"}` | malformed id |
| 404 | `{"error": "filter not found"}` | no such filter (or not owned) |
| 500 | `{"error": "failed to fetch filter"}` | unexpected server error |
@@ -0,0 +1,40 @@
---
method: GET
path: /api/saved-filters
auth: jwt
area: saved-filters
---
<!-- source: internal/handlers/filters.go:54 FiltersHandler.GetSavedFilters -->
# List Saved Filters
Returns the signed-in user's saved filters for a given resource type. The
`resource_type` query parameter is required.
## Example response
```json example-response
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "My Filter",
"resource_type": "media-items",
"filters": {},
"created_at": "2026-10-24T13:57:01Z",
"updated_at": "2026-10-24T13:57:01Z"
}
]
```
## Query parameters
| Param | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| resource_type | string | Yes | One of `media-items`, `collections`, `devices` |
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "resource_type query parameter is required"}` | missing `resource_type` |
| 500 | `{"error": "failed to fetch filters"}` | unexpected server error |
@@ -0,0 +1,50 @@
---
method: PUT
path: /api/saved-filters/:id
auth: jwt
area: saved-filters
---
<!-- source: internal/handlers/filters.go:200 FiltersHandler.UpdateSavedFilter -->
# Update Saved Filter
Updates an existing saved filter owned by the signed-in user.
## Example request
```json example-request
{
"name": "Renamed Filter",
"resource_type": "media-items",
"filters": {}
}
```
## Example response
```json example-response
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Renamed Filter",
"resource_type": "media-items",
"filters": {},
"created_at": "2026-10-24T13:57:01Z",
"updated_at": "2026-10-24T13:57:01Z"
}
```
## Fields
| Field | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| name | string | Yes | 1-100 chars |
| resource_type | string | Yes | One of `media-items`, `collections`, `devices` |
| filters | object | Yes | Map of filter key/value pairs |
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "invalid filter ID"}` / `{"error": "invalid request body"}` | malformed id or body |
| 409 | `{"error": "filter with name '...' already exists for this resource type"}` | duplicate name |
| 500 | `{"error": "failed to update filter"}` | unexpected server error |
@@ -0,0 +1,47 @@
---
method: GET
path: /api/series/books
auth: jwt
area: series
---
<!-- source: internal/handlers/series.go:112 SeriesHandler.GetSeriesBooks -->
# Get Series Books
Returns the books belonging to a named series within a library.
## Example response
```json example-response
{
"name": "The Witcher",
"library_id": "550e8400-e29b-41d4-a716-446655440000",
"books": [
{
"media_item_id": "550e8400-e29b-41d4-a716-446655440000",
"title": "The Last Wish",
"author": "Andrzej Sapkowski",
"cover_image_path": "",
"has_conflict": false,
"format_group": "ebook",
"badge": ""
}
],
"total": 1
}
```
## Query parameters
| Param | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| name | string | Yes | Series name |
| library_id | uuid | Yes | Library containing the series (must be visible) |
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "name required"}` / `{"error": "library_id required"}` / `{"error": "invalid library_id"}` | missing or malformed params |
| 403 | `{"error": "library not accessible"}` | library not in visible set |
| 500 | `{"error": "Failed to load series books"}` | unexpected server error |
+47
View File
@@ -0,0 +1,47 @@
---
method: GET
path: /api/series
auth: jwt
area: series
---
<!-- source: internal/handlers/series.go:29 SeriesHandler.GetSeries -->
# List Series
Returns a paginated list of series for the requesting user's visible libraries.
## Example response
```json example-response
{
"series": [
{
"name": "The Witcher",
"library_id": "550e8400-e29b-41d4-a716-446655440000",
"book_count": 5,
"total_in_series": 8,
"cover_paths": [],
"last_entry_at": "2026-10-24T13:57:01Z"
}
],
"total": 1,
"limit": 20,
"offset": 0
}
```
## Query parameters
| Param | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| library_id | uuid | No | Scope to one library (must be visible) |
| limit | int | No | 1-100 (default 20) |
| offset | int | No | Pagination offset |
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "invalid library_id"}` | malformed library_id |
| 403 | `{"error": "library not accessible"}` | library not in visible set |
| 500 | `{"error": "Failed to load series"}` | unexpected server error |
-77
View File
@@ -1,77 +0,0 @@
# System Config API
## Overview
Raw key/value system configuration storage (backed by the `system_config` table). Unlike the typed [System Settings API](settings.md), this endpoint reads and writes arbitrary config keys as plain strings — including keys without registry metadata, such as `base_url`.
**Base URL**: `/api/system`
**Authentication**: Admin JWT token required
**Content-Type**: `application/json`
---
## Endpoints
### Get System Configuration
Retrieve all system configuration entries as a flat key/value map.
**Endpoint**: `GET /api/system/config`
**Authentication**: Admin role required
**Response**: **200 OK**
```json
{
"base_url": "http://192.168.1.100:8765",
"default_timezone": "America/New_York"
}
```
**Example**:
```bash
curl -X GET https://bookhoard.example.com/api/system/config \
-H "Authorization: Bearer <admin_token>"
```
---
### Update System Configuration
Update one or more config values.
**Endpoint**: `PUT /api/system/config`
**Authentication**: Admin role required
**Request Body**: a flat map of keys to string values. Only the supplied keys are updated.
```json
{
"base_url": "https://bookhoard.example.com"
}
```
**Validation**: values for known keys are validated where applicable — for example, `default_timezone` must be a valid IANA timezone (`time.LoadLocation`); invalid values return `400` without persisting.
**Response**: **200 OK** on success; `400` (invalid value/format), `401`, `403`, `500` on failure.
**Example**:
```bash
curl -X PUT https://bookhoard.example.com/api/system/config \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{"base_url": "https://bookhoard.example.com"}'
```
> **Note:** settings that appear in the typed settings registry (e.g. `default_timezone`) are better managed through [`PUT /api/system/settings`](settings.md), which also returns metadata and reload hints. Writes through either endpoint refresh the shared registry cache.
---
## Related Endpoints
- [System Settings API](settings.md) — typed, validated tunable settings with metadata
- `GET /api/devices/:id/sidecar` — device setup config derived from system config (see [Devices API](../devices/))
+30
View File
@@ -0,0 +1,30 @@
---
method: GET
path: /api/system/config
auth: jwt+admin
area: system
---
<!-- source: internal/handlers/sidecar.go:374 SidecarHandler.GetSystemConfiguration -->
# Get System Configuration
Returns all system-wide configuration key/value pairs (backed by the
`system_config` table).
## Example response
```json example-response
{
"base_url": "https://books.example.com",
"opds_base_url": "https://books.example.com/opds",
"api_base_url": "https://books.example.com/api"
}
```
UNVERIFIED: exact config key set is arbitrary (admin-defined); example shows common keys only.
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 500 | `{"error": "failed to fetch system config"}` | unexpected server error |
+34
View File
@@ -0,0 +1,34 @@
---
method: GET
path: /api/system/settings
auth: jwt+admin
area: system
---
<!-- source: internal/handlers/system_settings.go:44 SystemSettingsHandler.GetSettings -->
# Get System Settings
Returns metadata and current values for every known tunable system setting.
## Example response
```json example-response
[
{
"key": "scan_poll_interval_seconds",
"value": "60",
"type": "int",
"requires_restart": false,
"category": "scanner",
"group": "Scanning",
"description": "How often to scan all libraries (seconds)",
"is_default": true
}
]
```
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 503 | `{"error": "settings registry not initialized"}` | registry not wired |
-196
View File
@@ -1,196 +0,0 @@
# System Settings API
## Overview
The System Settings API is the canonical way to read and write Bookhoard's tunable system settings (scanning, security, rate limits, sync, performance, and defaults). Every setting carries full metadata — type, range, category, description, and whether a restart is required — so the admin UI (and API clients) can render and validate settings generically.
**Base URL**: `/api/system`
**Authentication**: Admin JWT token required
**Content-Type**: `application/json`
---
## Endpoints
### List All Settings
Retrieve every known tunable setting with its current value and metadata.
**Endpoint**: `GET /api/system/settings`
**Authentication**: Admin role required
**Response**: **200 OK**
```json
[
{
"key": "scan_poll_interval_seconds",
"value": "60",
"type": "int",
"min": "1",
"max": "3600",
"requires_restart": false,
"category": "scanner",
"group": "Scanning",
"description": "How often to scan all libraries (seconds)",
"is_default": true
}
]
```
**Entry fields**:
| Field | Type | Description |
| ------------------ | ------- | -------------------------------------------------------- |
| `key` | string | Setting identifier (stable API name) |
| `value` | string | Current value (validated/clamped by the registry) |
| `type` | string | `int`, `bool`, or `string` |
| `min` / `max` | string | Range bounds for `int` settings (omitted otherwise) |
| `requires_restart` | boolean | Change takes effect only after a server restart |
| `category` | string | Coarse area: `scanner`, `security`, `api`, `sync`, `performance`, `general` |
| `group` | string | Sub-section shown in the admin UI |
| `description` | string | Human-readable description |
| `is_default` | boolean | True when the current value equals the compiled default |
**Example**:
```bash
curl -X GET https://bookhoard.example.com/api/system/settings \
-H "Authorization: Bearer <admin_token>"
```
---
### Update a Setting
Validate, persist, and reload a single setting.
**Endpoint**: `PUT /api/system/settings`
**Authentication**: Admin role required
**Request Body**:
```json
{
"key": "scan_poll_interval_seconds",
"value": "30"
}
```
| Field | Type | Required | Description |
| ------- | ------ | -------- | ------------------------------- |
| `key` | string | Yes | Setting key (from the list) |
| `value` | string | Yes | New value, as a string |
**Response**: **200 OK**
```json
{
"key": "scan_poll_interval_seconds",
"value": "30",
"type": "int",
"min": "1",
"max": "3600",
"requires_restart": false,
"category": "scanner",
"group": "Scanning",
"description": "How often to scan all libraries (seconds)",
"is_default": false,
"reload_required": false,
"message": ""
}
```
- `reload_required: true` means the change takes effect only after a restart (e.g. rate limits, worker pool, lockout settings).
- Validation is type-aware: `int` values are checked against `min`/`max`, `bool` values must parse, `default_timezone` must be a valid IANA timezone via `time.LoadLocation`, and strings must be non-empty.
**Errors**: `400` (unknown key, invalid value, out of range), `401`, `403`, `503` (settings registry not initialized).
**Example**:
```bash
curl -X PUT https://bookhoard.example.com/api/system/settings \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{"key": "scan_poll_interval_seconds", "value": "30"}'
```
---
## Setting Catalog
Current tunable settings by category:
**Scanner** (`scanner`)
| Key | Default | Range | Restart | Description |
| ------------------------------ | ------- | -------- | ------- | ----------------------------------------- |
| `scan_poll_interval_seconds` | `60` | 1-3600 | No | How often to scan all libraries (seconds) |
| `auto_scan_enabled` | `true` | - | No | Whether auto-scanning is enabled |
**General** (`general`)
| Key | Default | Restart | Description |
| ----------------- | ------- | ------- | ------------------------- |
| `default_timezone`| `UTC` | No | System default timezone |
**Security** (`security`)
| Key | Default | Range | Restart | Description |
| ---------------------------- | --------- | ------------ | ------- | ---------------------------------------------- |
| `session_duration_seconds` | `604800` | 300-31536000 | No | How long a login session stays valid |
| `password_min_length` | `8` | 1-128 | No | Minimum password length |
| `password_require_upper` | `true` | - | No | Require at least one uppercase letter |
| `password_require_lower` | `true` | - | No | Require at least one lowercase letter |
| `password_require_number` | `true` | - | No | Require at least one number |
| `password_require_special` | `true` | - | No | Require at least one special character |
| `auth_rate_limit_per_min` | `10` | 1-10000 | **Yes** | Global auth API rate limit (req/min) |
| `login_max_attempts` | `5` | 1-100 | **Yes** | Failed login attempts before lockout |
| `login_lockout_minutes` | `15` | 1-10080 | **Yes** | Lockout duration after failed logins |
**API** (`api`)
| Key | Default | Range | Restart | Description |
| ------------------------------- | ------- | --------- | ------- | ------------------------------------ |
| `opds_default_page_size` | `50` | 1-500 | No | Default OPDS page size |
| `opds_max_page_size` | `200` | 1-1000 | No | Maximum OPDS page size |
| `device_rate_sync_per_min` | `60` | 1-10000 | No | Device sync requests per minute |
| `device_rate_progress_per_min` | `120` | 1-10000 | No | Device progress requests per minute |
| `device_rate_metadata_per_min` | `30` | 1-10000 | No | Device metadata requests per minute |
**Sync** (`sync`)
| Key | Default | Range | Restart | Description |
| ------------------------------- | ------- | -------- | ------- | -------------------------------------------------- |
| `annotation_tombstone_ttl_days` | `30` | 1-3650 | No | How long deleted annotations are kept before purge |
| `sync_queue_interval_seconds` | `5` | 1-3600 | **Yes** | How often the sync queue flushes |
| `sync_queue_batch_size` | `50` | 1-10000 | **Yes** | Max items processed per sync queue flush |
**Performance** (`performance`)
| Key | Default | Range | Restart | Description |
| ------------------------ | ------- | --------- | ------- | ------------------------------------------- |
| `conversion_cache_ttl_hours` | `24` | 1-720 | No | How long converted (KEPUB) files are cached |
| `worker_pool_size` | `3` | 1-100 | **Yes** | Number of background worker goroutines |
| `worker_queue_cap` | `100` | 1-10000 | **Yes** | Background worker job queue capacity |
---
## Legacy Scan Settings Routes
The older JSON routes still work for backward compatibility and now refresh the settings registry cache on write, but they are **superseded** by `GET/PUT /api/system/settings`:
- `GET /api/libraries/scan-settings` — returns only `scan_poll_interval_seconds` and `auto_scan_enabled`
- `PUT /api/libraries/scan-settings` — accepts `{ "scan_poll_interval_seconds": int, "auto_scan_enabled": bool }`
Both fields are backed by the same registry entries documented above.
---
## Related Endpoints
- `GET/PUT /api/system/config` — raw key/value system configuration (see [System Config API](config.md))
- `POST /api/scanner/scan` — trigger a manual scan (see [Scanner API](../scanner/))
- `GET /api/admin/hash-conflicts` — duplicates found during hashing (see [Hash Conflicts API](../admin/hash-conflicts.md))
@@ -0,0 +1,46 @@
---
method: PUT
path: /api/system/config
auth: jwt+admin
area: system
---
<!-- source: internal/handlers/sidecar.go:396 SidecarHandler.UpdateSystemConfiguration -->
# Update System Configuration
Updates one or more system-wide configuration keys. Admin only. When
`base_url` is provided, derived keys (`opds_base_url`, `api_base_url`) are
updated automatically. `default_timezone` is validated as a timezone.
## Example request
```json example-request
{
"base_url": "https://books.example.com",
"default_timezone": "UTC"
}
```
## Example response
```json example-response
{
"status": "success",
"message": "System configuration updated"
}
```
## Fields
| Field | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| (any key) | string | No | Arbitrary config key/value pairs |
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "invalid request format"}` / `{"error": "invalid timezone"}` | malformed body or bad timezone |
| 401 | `{"error": "unauthorized"}` | no/invalid JWT |
| 403 | `{"error": "admin access required"}` | non-admin |
| 500 | `{"error": "failed to update config key: ..."}` / `{"error": "failed to update default timezone"}` / `{"error": "failed to update derived config key: ..."}` | unexpected server error |
@@ -0,0 +1,50 @@
---
method: PUT
path: /api/system/settings
auth: jwt+admin
area: system
---
<!-- source: internal/handlers/system_settings.go:65 SystemSettingsHandler.UpdateSetting -->
# Update System Setting
Validates, persists, and reloads a single tunable system setting. Admin only.
## Example request
```json example-request
{
"key": "scan_poll_interval_seconds",
"value": "60"
}
```
## Example response
```json example-response
{
"key": "scan_poll_interval_seconds",
"value": "60",
"type": "int",
"requires_restart": false,
"category": "scanner",
"group": "Scanning",
"description": "How often to scan all libraries (seconds)",
"is_default": true,
"reload_required": false
}
```
## Fields
| Field | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| key | string | Yes | Known setting key |
| value | string | Yes | Value (validated against type/bounds) |
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "invalid request"}` / `{"error": "key is required"}` / `{"error": "unknown setting key: ..."}` / `{"error": "value must be ..."}` | malformed body or invalid value |
| 503 | `{"error": "settings registry not initialized"}` | registry not wired |
+19
View File
@@ -221,6 +221,15 @@ func validateRequestExample(t *testing.T, doc *APIDoc, schema Schema) {
return return
} }
// AnyJSONObject request: arbitrary keys (config maps). Shape-check only.
if _, ok := schema.Request.(AnyJSONObject); ok {
var m map[string]json.RawMessage
if err := json.Unmarshal(doc.ExampleRequest, &m); err != nil {
t.Errorf("%s: example-request must be a JSON object", doc.RelPath)
}
return
}
// Typo guard: field names must exist on the bind struct. // Typo guard: field names must exist on the bind struct.
dec := json.NewDecoder(bytes.NewReader(doc.ExampleRequest)) dec := json.NewDecoder(bytes.NewReader(doc.ExampleRequest))
dec.DisallowUnknownFields() dec.DisallowUnknownFields()
@@ -257,6 +266,16 @@ func validateResponseExample(t *testing.T, doc *APIDoc, schema Schema) {
return return
} }
// AnyJSONObject: response is an object with arbitrary/dynamic keys
// (e.g. map[string]string config). Only shape-check, never key-check.
if _, ok := schema.Response.(AnyJSONObject); ok {
var m map[string]json.RawMessage
if err := json.Unmarshal(doc.ExampleResponse, &m); err != nil {
t.Errorf("%s: example-response must be a JSON object", doc.RelPath)
}
return
}
want, isSlice := responseTopLevelKeys(t, schema.Response) want, isSlice := responseTopLevelKeys(t, schema.Response)
var got map[string]bool var got map[string]bool
+70
View File
@@ -34,6 +34,10 @@ type Schema struct {
Response any Response any
} }
// AnyJSONObject marks responses whose keys are dynamic (admin-defined config
// maps etc.): the gate then only verifies the example is a JSON object.
type AnyJSONObject struct{}
// Schemas is keyed by "METHOD path" in Echo syntax. Sample values inside // Schemas is keyed by "METHOD path" in Echo syntax. Sample values inside
// RawMessage pins use empty strings / zero values — only the KEY SET is // RawMessage pins use empty strings / zero values — only the KEY SET is
// checked, never the values. // checked, never the values.
@@ -254,4 +258,70 @@ var Schemas = map[string]Schema{
"GET /api/jobs/:jobId": { "GET /api/jobs/:jobId": {
Response: services.JobResult{}, Response: services.JobResult{},
}, },
// --- WO3 tail batch: saved-filters, dashboard, system, series ---
"GET /api/saved-filters": {
Response: []handlers.SavedFilterResponse{},
},
"GET /api/saved-filters/:id": {
Response: handlers.SavedFilterResponse{},
},
"POST /api/saved-filters": {
Request: handlers.CreateSavedFilterRequest{},
Response: handlers.SavedFilterResponse{},
},
"PUT /api/saved-filters/:id": {
Request: handlers.CreateSavedFilterRequest{}, // same struct reused for update
Response: handlers.SavedFilterResponse{},
},
"DELETE /api/saved-filters/:id": {},
"GET /api/dashboard/sections": {
Response: json.RawMessage(`{"sections": []}`),
},
"GET /api/dashboard/preferences": {
Response: json.RawMessage(`{
"hidden_collections": [], "collection_order": [],
"items_per_section": 0
}`),
},
"PUT /api/dashboard/preferences": {
Request: json.RawMessage(`{
"library_id": null, "hidden_collections": [],
"collection_order": [], "items_per_section": 0
}`),
Response: database.UserDashboardPreferences{},
},
"POST /api/dashboard/restore-system-collection": {
Request: json.RawMessage(`{"collection_name": "", "reset_type": ""}`),
Response: json.RawMessage(`{"message": ""}`),
},
"GET /api/system/config": {
// Admin-defined key/value map — arbitrary keys.
Response: AnyJSONObject{},
},
"PUT /api/system/config": {
Request: AnyJSONObject{}, // map[string]string of key -> new value
Response: json.RawMessage(`{"status": "", "message": ""}`),
},
"GET /api/system/settings": {
Response: []database.SettingEntry{},
},
"PUT /api/system/settings": {
Request: handlers.UpdateSettingRequest{},
Response: handlers.UpdateSettingResponse{},
},
"GET /api/series": {
Response: json.RawMessage(`{
"series": [{
"name": "", "library_id": "", "book_count": 0,
"total_in_series": 0, "cover_paths": [], "last_entry_at": ""
}],
"total": 0, "limit": 0, "offset": 0
}`),
},
"GET /api/series/books": {
Response: json.RawMessage(`{
"name": "", "library_id": "", "books": [], "total": 0
}`),
},
} }