diff --git a/docs/developer/api/libraries/delete_processing_issue.md b/docs/developer/api/libraries/delete_processing_issue.md new file mode 100644 index 00000000..e04b972a --- /dev/null +++ b/docs/developer/api/libraries/delete_processing_issue.md @@ -0,0 +1,29 @@ +--- +method: DELETE +path: /api/libraries/:id/issues/:issueId +auth: jwt+admin +area: libraries +--- + + +# Delete Processing Issue + +Permanently deletes a processing issue record (distinct from resolving it). + +## Example response + +```json example-response +{ + "success": true, + "message": "Issue deleted" +} +``` + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "Invalid issue ID"}` | malformed id | +| 403 | — | issue's library not in requester's visible set | +| 404 | — | no such issue | +| 500 | `{"error": "Failed to delete issue"}` | unexpected server error | diff --git a/docs/developer/api/libraries/get_library_folders.md b/docs/developer/api/libraries/get_library_folders.md new file mode 100644 index 00000000..17e8191e --- /dev/null +++ b/docs/developer/api/libraries/get_library_folders.md @@ -0,0 +1,32 @@ +--- +method: GET +path: /api/libraries/:id/folders +auth: jwt+admin +area: libraries +--- + + +# Get Library Folders + +Lists the filesystem folders configured for a library. + +## Example response + +```json example-response +[ + { + "id": "aa0e8400-e29b-41d4-a716-446655440000", + "library_id": "990e8400-e29b-41d4-a716-446655440000", + "folder_path": "/books/manga", + "created_at": "2026-01-01T00:00:00Z" + } +] +``` + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "invalid library id"}` | malformed id | +| 401 / 403 | — | missing JWT / not an admin | +| 500 | `{"error": "..."}` | unexpected server error | diff --git a/docs/developer/api/libraries/get_library_types.md b/docs/developer/api/libraries/get_library_types.md new file mode 100644 index 00000000..2f67e3dc --- /dev/null +++ b/docs/developer/api/libraries/get_library_types.md @@ -0,0 +1,33 @@ +--- +method: GET +path: /api/libraries/types +auth: none +area: libraries +--- + + +# Get Library Types + +Public endpoint: lists the library types the server supports (ebooks, comics, +audiobooks, …) with their allowed file extensions. Used by the create-library +form before login is required. + +## Example response + +```json example-response +[ + { + "id": "880e8400-e29b-41d4-a716-446655440000", + "name": "ebooks", + "description": "Electronic books", + "allowed_extensions": [".epub", ".pdf", ".mobi"], + "created_at": "2026-01-01T00:00:00Z" + } +] +``` + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 500 | `{"error": "..."}` | unexpected server error | diff --git a/docs/developer/api/libraries/get_processing_issue_stats.md b/docs/developer/api/libraries/get_processing_issue_stats.md new file mode 100644 index 00000000..05c69a51 --- /dev/null +++ b/docs/developer/api/libraries/get_processing_issue_stats.md @@ -0,0 +1,29 @@ +--- +method: GET +path: /api/libraries/:id/issues/stats +auth: jwt+admin +area: libraries +--- + + +# Get Processing Issue Stats + +Severity counts for a library's processing issues. + +## Example response + +```json example-response +{ + "error_count": 2, + "warning_count": 5, + "info_count": 1 +} +``` + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 403 | — | library not in requester's visible set (family lens) | +| 404 | — | no such library | +| 500 | `{"error": "Failed to fetch stats"}` | unexpected server error | diff --git a/docs/developer/api/libraries/get_scan_settings.md b/docs/developer/api/libraries/get_scan_settings.md new file mode 100644 index 00000000..b8f35ec6 --- /dev/null +++ b/docs/developer/api/libraries/get_scan_settings.md @@ -0,0 +1,30 @@ +--- +method: GET +path: /api/libraries/scan-settings +auth: jwt+admin +area: libraries +--- + + +# Get Scan Settings + +Returns the global scan poll interval and auto-scan flag (system settings, +admin-scoped). Defaults to 1800s / enabled when unset. + +## Example response + +```json example-response +{ + "scan_poll_interval_seconds": 1800, + "auto_scan_enabled": true +} +``` + +`message` appears only in the update response (`omitempty`). + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 401 / 403 | — | missing JWT / not an admin | +| 500 | `{"error": "..."}` | unexpected server error | diff --git a/docs/developer/api/libraries/list_libraries.md b/docs/developer/api/libraries/list_libraries.md new file mode 100644 index 00000000..67612804 --- /dev/null +++ b/docs/developer/api/libraries/list_libraries.md @@ -0,0 +1,43 @@ +--- +method: GET +path: /api/libraries +auth: jwt+admin +area: libraries +--- + + +# List Libraries (Admin) + +Lists **all** libraries regardless of per-user visibility. (Non-admin users +see their subset via `GET /api/libraries/visibility`.) + +## Example response + +```json example-response +{ + "data": [ + { + "id": "990e8400-e29b-41d4-a716-446655440000", + "name": "Manga", + "description": null, + "library_type_id": "880e8400-e29b-41d4-a716-446655440000", + "created_by_admin_id": "92413c3a-e1b8-4fff-b3f9-95c74478d19a", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "type_name": "manga", + "type_description": null + } + ] +} +``` + +Nullable fields (`description`, `type_description`) are JSON `null` when +unset. `type_name`/`type_description` come from the joined library type. + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 401 | — | missing/invalid JWT | +| 403 | — | not an admin | +| 500 | `{"error": "..."}` | unexpected server error | diff --git a/docs/developer/api/libraries/list_processing_issues.md b/docs/developer/api/libraries/list_processing_issues.md new file mode 100644 index 00000000..e3be56e0 --- /dev/null +++ b/docs/developer/api/libraries/list_processing_issues.md @@ -0,0 +1,40 @@ +--- +method: GET +path: /api/libraries/:id/issues/list +auth: jwt+admin +area: libraries +--- + + +# List Processing Issues + +Lists scan/processing issues for a library (import failures, metadata +problems). Row-less import failures have no media item — their +`media_item_id` is `""` and `title` falls back to the file path. + +## Example response + +```json example-response +[ + { + "id": "bb0e8400-e29b-41d4-a716-446655440000", + "media_item_id": "cc0e8400-e29b-41d4-a716-446655440000", + "title": "1984", + "file_path": "/books/1984.epub", + "format_group": "text", + "library_type_name": "ebooks", + "issue_type": "import_failed", + "issue_description": "unsupported format", + "severity": "error", + "created_at": "2026-10-24T13:57:01Z" + } +] +``` + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 403 | — | library not in requester's visible set (family lens) | +| 404 | — | no such library | +| 500 | `{"error": "Failed to fetch issues"}` | unexpected server error | diff --git a/docs/developer/api/libraries/resolve_processing_issue.md b/docs/developer/api/libraries/resolve_processing_issue.md new file mode 100644 index 00000000..d1781571 --- /dev/null +++ b/docs/developer/api/libraries/resolve_processing_issue.md @@ -0,0 +1,30 @@ +--- +method: POST +path: /api/libraries/:id/issues/:issueId/:mediaItemId/resolve +auth: jwt+admin +area: libraries +--- + + +# Resolve Processing Issue + +Marks one processing issue as resolved. The issue's library must be in the +requester's visible set. + +## Example response + +```json example-response +{ + "success": true, + "message": "Issue marked as resolved" +} +``` + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "Invalid issue ID"}` / `{"error": "Invalid media item ID"}` | malformed path params | +| 403 | — | issue's library not in requester's visible set | +| 404 | — | no such issue | +| 500 | `{"error": "Failed to resolve issue"}` | unexpected server error | diff --git a/docs/developer/api/libraries/scan_library.md b/docs/developer/api/libraries/scan_library.md new file mode 100644 index 00000000..3cb74597 --- /dev/null +++ b/docs/developer/api/libraries/scan_library.md @@ -0,0 +1,35 @@ +--- +method: POST +path: /api/libraries/:id/scan +auth: jwt+admin +area: libraries +--- + + +# Scan Library + +Enqueues a full scan of one library's configured folders. The library id comes +from the path — no request body is needed (the generic `POST /api/scanner/scan` +variant is the one that takes `folder_paths`). + +Scans are **coalesced**: if a scan for this library is already pending or +running you get **409** with the active job id instead of a second scan. + +## Example response (202 Accepted) + +```json example-response +{ + "message": "scan job enqueued", + "job_id": "dd0e8400-e29b-41d4-a716-446655440000", + "status": "pending" +} +``` + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "invalid library id"}` / `{"error": "library has no folders configured"}` | malformed id / empty library | +| 404 | `{"error": "library not found or has no folders"}` | unknown library | +| 409 | `{"error": "a scan for this library is already pending or running", "active_job_id": "..."}` | scan coalescing | +| 500 | `{"error": "failed to enqueue scan job: ..."}` | queue failure | diff --git a/docs/developer/api/libraries/update_scan_settings.md b/docs/developer/api/libraries/update_scan_settings.md new file mode 100644 index 00000000..2f38ff4e --- /dev/null +++ b/docs/developer/api/libraries/update_scan_settings.md @@ -0,0 +1,47 @@ +--- +method: PUT +path: /api/libraries/scan-settings +auth: jwt+admin +area: libraries +--- + + +# Update Scan Settings + +Sets the global scan poll interval and auto-scan flag, then reloads the +settings registry so running code picks the new values up. + +## Example request + +```json example-request +{ + "scan_poll_interval_seconds": 3600, + "auto_scan_enabled": true +} +``` + +## Example response + +```json example-response +{ + "scan_poll_interval_seconds": 3600, + "auto_scan_enabled": true, + "message": "scan settings updated successfully" +} +``` + +## Request fields + +| Field | Type | Required | Notes | +| ----- | ---- | -------- | ----- | +| scan_poll_interval_seconds | number | yes | 1–3600 | +| auto_scan_enabled | boolean | no | defaults false when omitted | + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "..."}` | validation failure | +| 401 / 403 | — | missing JWT / not an admin | +| 404 | `{"error": "system setting not found"}` | settings rows missing from DB | +| 500 | `{"error": "..."}` | unexpected server error | diff --git a/internal/docs/drift_test.go b/internal/docs/drift_test.go index 520dc66c..79c10e75 100644 --- a/internal/docs/drift_test.go +++ b/internal/docs/drift_test.go @@ -251,8 +251,24 @@ func validateResponseExample(t *testing.T, doc *APIDoc, schema Schema) { return } - want := responseTopLevelKeys(t, schema.Response) - got := topLevelKeys(doc.ExampleResponse) + want, isSlice := responseTopLevelKeys(t, schema.Response) + + var got map[string]bool + if isSlice { + // Array responses: compare the first sample element's keys. + var arr []json.RawMessage + if err := json.Unmarshal(doc.ExampleResponse, &arr); err != nil { + t.Errorf("%s: example-response must be a JSON array for this route (handler returns a slice)", doc.RelPath) + return + } + if len(arr) == 0 { + t.Errorf("%s: example-response array needs at least one sample element to check keys", doc.RelPath) + return + } + got = topLevelKeys(arr[0]) + } else { + got = topLevelKeys(doc.ExampleResponse) + } for _, k := range sortedKeys(want) { if !got[k] { @@ -312,21 +328,25 @@ func topLevelKeys(data []byte) map[string]bool { // responseTopLevelKeys derives the expected top-level key set from the // registered Response: marshal a struct (or its element for slices), or use a -// RawMessage pin verbatim. -func responseTopLevelKeys(t *testing.T, resp any) map[string]bool { +// RawMessage pin verbatim. The second return reports that the handler emits a +// JSON ARRAY (slice response) — the doc's example must then be an array too. +func responseTopLevelKeys(t *testing.T, resp any) (map[string]bool, bool) { t.Helper() if raw, ok := resp.(json.RawMessage); ok { - return topLevelKeys(raw) + trimmed := bytes.TrimLeft(raw, " \t\r\n") + return topLevelKeys(raw), len(trimmed) > 0 && trimmed[0] == '[' } rt := reflect.TypeOf(resp) if rt == nil { - return nil + return nil, false } + isSlice := false if rt.Kind() == reflect.Slice { if rt.Elem().Kind() == reflect.Uint8 { - return topLevelKeys(reflect.ValueOf(resp).Bytes()) // []byte payloads + return topLevelKeys(reflect.ValueOf(resp).Bytes()), false // []byte payloads } rt = rt.Elem() + isSlice = true } if rt.Kind() == reflect.Ptr { rt = rt.Elem() @@ -338,7 +358,7 @@ func responseTopLevelKeys(t *testing.T, resp any) map[string]bool { if err != nil { t.Fatalf("marshal schema response %T: %v", resp, err) } - return topLevelKeys(b) + return topLevelKeys(b), isSlice } func sortedKeys(m map[string]bool) []string { diff --git a/internal/docs/schema.go b/internal/docs/schema.go index f41652b2..fff7ab64 100644 --- a/internal/docs/schema.go +++ b/internal/docs/schema.go @@ -3,6 +3,7 @@ package docs import ( "encoding/json" + "bookhoard/internal/database" "bookhoard/internal/handlers" "bookhoard/internal/services" ) @@ -89,9 +90,9 @@ var Schemas = map[string]Schema{ }, "GET /api/devices/:id/collections": { Response: struct { - DeviceID string `json:"device_id"` - Mappings []handlers.DeviceMappingSummary `json:"mappings"` - Total int `json:"total"` + DeviceID string `json:"device_id"` + Mappings []handlers.DeviceMappingSummary `json:"mappings"` + Total int `json:"total"` }{}, }, "PUT /api/devices/:id/collections/:collectionId": { @@ -119,4 +120,47 @@ var Schemas = map[string]Schema{ } }`), }, + + // --- libraries (batch 2) --- + "GET /api/libraries/types": { + Response: []database.LibraryTypes{}, + }, + "GET /api/libraries": { + Response: struct { + Data []database.ListLibrariesRow `json:"data"` + }{}, + }, + "GET /api/libraries/:id/folders": { + Response: []database.LibraryFolders{}, + }, + "GET /api/libraries/:id/issues/list": { + Response: []handlers.ProcessingIssueResponse{}, + }, + "GET /api/libraries/:id/issues/stats": { + Response: handlers.ProcessingIssueStats{}, + }, + "POST /api/libraries/:id/issues/:issueId/:mediaItemId/resolve": { + Response: json.RawMessage(`{"success": false, "message": ""}`), + }, + "DELETE /api/libraries/:id/issues/:issueId": { + Response: json.RawMessage(`{"success": false, "message": ""}`), + }, + "POST /api/libraries/:id/scan": { + Request: nil, // library_id injected from the path; no body used + Response: json.RawMessage(`{"message": "", "job_id": "", "status": ""}`), + }, + "GET /api/libraries/scan-settings": { + Response: handlers.ScanSettingsResponse{}, + }, + "PUT /api/libraries/scan-settings": { + Request: handlers.UpdateScanSettingsRequest{}, + // RawMessage pin (not the struct): the update handler ALWAYS sets + // Message, but the struct's omitempty would drop it from a + // zero-value marshal and the gate would reject correct docs. + Response: json.RawMessage(`{ + "scan_poll_interval_seconds": 0, + "auto_scan_enabled": false, + "message": "" + }`), + }, }