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:
1 parent
7471ca99e8
commit
fa00d6d3cb
20 files changed
+714
-416
No files matched your search
@@ -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 |
|
||||||
@@ -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 |
|
||||||
@@ -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/))
|
|
||||||
@@ -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 |
|
||||||
@@ -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 |
|
||||||
@@ -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 |
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}`),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user