POST /api/libraries/:id/issues/:issueId/retry (admin, family-lens gated via the existing gateIssue) re-runs the work that produced an open issue — deliberately NOT a library-wide rescan. Two shapes mirror the two ledger entry kinds: - item-backed issues rescan their media item through the book-detail RescanMediaItem mechanism (self-resolving folders, multi-file audiobook routing, write-after-success guards) and then close the issue (RetryMediaItemIssue); - row-less import_failed issues re-import the single file through the watcher's standalone processMediaFile path (RetryFailedImport: folders resolved RescanMediaItem-style so paths stay library-root-relative), whose create-path self-heal plus an explicit path resolve closes the issue even when the content was already imported by a prior attempt. Failures return the REAL underlying error so the UI can show why the issue still exists; a vanished file is a 409 (ErrRetryFileMissing); already-resolved issues reject re-retry. Docs + bruno mirrors regenerated for the new route (the generator's seq renumbering touches three unrelated bruno files as fallout; openapi gains the path and the date bump), and the docs schema registry gains the retry entry so TestDocsMatchRoutes passes.
986 lines
33 KiB
Go
986 lines
33 KiB
Go
package docs
|
|
|
|
import (
|
|
"encoding/json"
|
|
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/handlers"
|
|
"bookhoard/internal/services"
|
|
"bookhoard/internal/sync"
|
|
)
|
|
|
|
// Schema pins the machine-checkable contract for one route. The drift gate
|
|
// (drift_test.go) validates every frontmatter'd doc against the entry for its
|
|
// "METHOD path" key.
|
|
//
|
|
// MAINTENANCE CONTRACT: every doc with frontmatter MUST have a registry
|
|
// entry, and every new/changed route that ships a frontmatter'd doc updates
|
|
// its entry in the same commit. The gate fails loudly on a missing entry —
|
|
// that is the discipline, not an inconvenience.
|
|
//
|
|
// Request: the struct the handler Binds into (nil for bodyless routes).
|
|
// - example-request is decoded with unknown fields DISALLOWED (typo guard)
|
|
// - fields whose validate tag contains "required" must appear in the
|
|
// example (staleness guard)
|
|
//
|
|
// Response: what the handler passes to c.JSON.
|
|
// - struct / slice of struct: marshaled and its TOP-LEVEL key set compared
|
|
// against example-response in both directions
|
|
// - json.RawMessage: hand-built map responses pin their key set as a
|
|
// sample; promote to a struct when the endpoint is next touched
|
|
// - zero value (Schema{}): no JSON body expected (e.g. 204) — the doc must
|
|
// not carry an example-response block
|
|
type Schema struct {
|
|
Request 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
|
|
// RawMessage pins use empty strings / zero values — only the KEY SET is
|
|
// checked, never the values.
|
|
var Schemas = map[string]Schema{
|
|
// --- collections (pilot area) ---
|
|
"POST /api/collections": {
|
|
Request: handlers.CreateCollectionRequest{},
|
|
Response: json.RawMessage(`{
|
|
"id": "", "user_id": "", "name": "", "description": "",
|
|
"color": "", "icon": "", "collection_type": "",
|
|
"auto_assign_rules": null, "view_settings": {},
|
|
"book_count": 0, "created_at": ""
|
|
}`),
|
|
},
|
|
"GET /api/collections": {
|
|
Response: struct {
|
|
Collections []handlers.CollectionSummary `json:"collections"`
|
|
Total int `json:"total"`
|
|
}{},
|
|
},
|
|
"GET /api/collections/:id": {
|
|
Response: json.RawMessage(`{
|
|
"id": "", "user_id": "", "name": "", "description": "",
|
|
"color": "", "icon": "", "auto_assign_rules": null,
|
|
"view_settings": {}, "books": [], "book_count": 0,
|
|
"is_smart": false, "up_next": [], "created_at": ""
|
|
}`),
|
|
},
|
|
"PUT /api/collections/:id": {
|
|
Request: handlers.UpdateCollectionRequest{},
|
|
Response: json.RawMessage(`{
|
|
"id": "", "user_id": "", "name": "", "description": "",
|
|
"color": "", "icon": "", "auto_assign_rules": null,
|
|
"view_settings": {}, "created_at": ""
|
|
}`),
|
|
},
|
|
"DELETE /api/collections/:id": {},
|
|
// --- devices (batch 1) ---
|
|
"GET /api/devices/:id": {
|
|
Response: handlers.DeviceInfo{},
|
|
},
|
|
"PUT /api/devices/:id/regenerate-token": {
|
|
Response: json.RawMessage(`{
|
|
"message": "", "auth_token": "",
|
|
"device": {
|
|
"id": "", "device_name": "", "device_type": "",
|
|
"last_sync": null, "last_seen": null,
|
|
"sync_enabled": false, "auto_sync": false,
|
|
"sync_frequency_minutes": 0, "created_at": "",
|
|
"device_metadata": null, "auth_token": ""
|
|
},
|
|
"sync_urls": {}
|
|
}`),
|
|
},
|
|
"GET /api/devices/:id/collections": {
|
|
Response: struct {
|
|
DeviceID string `json:"device_id"`
|
|
Mappings []handlers.DeviceMappingSummary `json:"mappings"`
|
|
Total int `json:"total"`
|
|
}{},
|
|
},
|
|
"PUT /api/devices/:id/collections/:collectionId": {
|
|
Request: handlers.UpdateDeviceMappingRequest{},
|
|
Response: json.RawMessage(`{
|
|
"id": "", "collection_id": "", "device_id": "",
|
|
"device_shelf_name": "", "sync_direction": "", "created_at": ""
|
|
}`),
|
|
},
|
|
"GET /api/devices/:id/sidecar/download": {
|
|
Response: json.RawMessage(`{
|
|
"version": "", "bookhoard": {}, "books": {},
|
|
"collections": [], "opds_enabled": false,
|
|
"sidecar_enabled": false, "last_updated": ""
|
|
}`),
|
|
},
|
|
"POST /api/devices/:deviceId/sync/link-book": {
|
|
Request: services.LinkBookRequest{},
|
|
Response: json.RawMessage(`{
|
|
"status": "",
|
|
"device_file_alias": {
|
|
"id": "", "media_item_id": "", "device_id": "",
|
|
"file_path": "", "file_sha256": "",
|
|
"confidence_score": 0.0
|
|
}
|
|
}`),
|
|
},
|
|
|
|
// --- 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": ""}`),
|
|
},
|
|
"POST /api/libraries/:id/issues/:issueId/retry": {
|
|
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": ""
|
|
}`),
|
|
},
|
|
|
|
// --- media-items batch 3a: reader-adjacent CRUD ---
|
|
"GET /api/media-items/:id/bookmarks": {
|
|
Response: []database.MediaBookmarks{},
|
|
},
|
|
"POST /api/media-items/:id/bookmarks": {
|
|
Request: handlers.CreateMediaBookmarkRequest{},
|
|
Response: database.MediaBookmarks{},
|
|
},
|
|
"PUT /api/media-items/:id/bookmarks/:bookmarkId": {
|
|
Request: handlers.UpdateMediaBookmarkRequest{},
|
|
Response: database.MediaBookmarks{},
|
|
},
|
|
"DELETE /api/media-items/:id/bookmarks/:bookmarkId": {},
|
|
// Review: the upsert binds an inline anonymous struct {review}; pinned
|
|
// by RawMessage. Empty review string = delete, returns {"review": null}.
|
|
"POST /api/media-items/:id/review": {
|
|
Request: json.RawMessage(`{"review": ""}`),
|
|
Response: database.MediaReviews{},
|
|
},
|
|
"PUT /api/media-items/:id/review": {
|
|
Request: json.RawMessage(`{"review": ""}`),
|
|
Response: database.MediaReviews{},
|
|
},
|
|
"GET /api/media-items/:id/review": {
|
|
Response: database.MediaReviews{},
|
|
},
|
|
"DELETE /api/media-items/:id/review": {
|
|
Response: json.RawMessage(`{"message": ""}`),
|
|
},
|
|
"GET /api/media-items/:id/notes/:noteId": {
|
|
Response: database.MediaNotes{},
|
|
},
|
|
"GET /api/media-items/:id/highlights/:highlightId": {
|
|
Response: database.MediaHighlights{},
|
|
},
|
|
"POST /api/media-items/:id/annotations/:annotationId/restore": {
|
|
Request: nil, // kind via ?annotation_type= or optional body
|
|
Response: json.RawMessage(`{"restored": false}`),
|
|
},
|
|
"DELETE /api/media-items/:id/annotations/:annotationId": {
|
|
Request: nil, // kind via required ?annotation_type=
|
|
Response: json.RawMessage(`{"purged": false}`),
|
|
},
|
|
|
|
// --- media-items batch 3b: management ---
|
|
"GET /api/media-items/:id/download": {
|
|
// Serves raw file bytes, not JSON — no examples by design.
|
|
},
|
|
"PUT /api/media-items/:id/audio-speed": {
|
|
Request: json.RawMessage(`{"speed": 0}`),
|
|
Response: json.RawMessage(`{"playback_speed": 0}`),
|
|
},
|
|
"POST /api/media-items/:id/rescan": {
|
|
// Returns the FULL media_items row (70+ columns); pin is an abridged
|
|
// key subset — the doc example declares the same subset and says so.
|
|
Response: json.RawMessage(`{
|
|
"id": "", "library_id": "", "title": "", "author": "",
|
|
"file_path": "", "mime_type": "", "scan_status": "", "updated_at": ""
|
|
}`),
|
|
},
|
|
"POST /api/media-items/purge-archived": {
|
|
Response: json.RawMessage(`{"purged": 0}`),
|
|
},
|
|
"POST /api/media-items/:id/unarchive": {},
|
|
"POST /api/media-items/:id/merge": {
|
|
Request: json.RawMessage(`{"target_id": ""}`),
|
|
Response: json.RawMessage(`{"message": ""}`),
|
|
},
|
|
"GET /api/media-items/facets": {
|
|
Response: json.RawMessage(`{"facet": "", "values": [{}]}`),
|
|
},
|
|
"GET /api/media-items/filter-count": {
|
|
Response: json.RawMessage(`{"count": 0}`),
|
|
},
|
|
|
|
// --- analytics + jobs (batch 4) ---
|
|
"GET /api/analytics/device-usage": {
|
|
Response: handlers.DeviceUsageResponse{},
|
|
},
|
|
"GET /api/analytics/popular-books": {
|
|
Response: handlers.PopularBooksResponse{},
|
|
},
|
|
"POST /api/jobs": {
|
|
// Inline anonymous request struct {type, params} — pinned by sample.
|
|
Request: json.RawMessage(`{"type": "", "params": {}}`),
|
|
Response: json.RawMessage(`{"message": "", "job_id": "", "type": "", "status": ""}`),
|
|
},
|
|
"GET /api/jobs/:jobId": {
|
|
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
|
|
}`),
|
|
},
|
|
|
|
// --- Beam batch 2: conflicts + queue backfill ---
|
|
"GET /api/conflicts": {
|
|
Response: handlers.ConflictListResponse{},
|
|
},
|
|
"GET /api/conflicts/:id": {
|
|
Response: handlers.ConflictDetailResponse{},
|
|
},
|
|
"DELETE /api/conflicts/:id": {},
|
|
"POST /api/conflicts/:id/resolve": {
|
|
Request: handlers.ConflictResolutionRequest{},
|
|
Response: handlers.ConflictResolveResponse{},
|
|
},
|
|
"POST /api/conflicts/dismiss-all": {
|
|
// deleted is an INT count of dismissed rows.
|
|
Response: json.RawMessage(`{"deleted": 0}`),
|
|
},
|
|
"POST /api/conflicts/bulk-resolve": {
|
|
Request: handlers.BulkResolveRequest{},
|
|
Response: handlers.BulkResolveResponse{},
|
|
},
|
|
"POST /api/conflicts/bulk-dismiss": {
|
|
// Inline anonymous request struct {conflict_ids} — pinned by sample.
|
|
Request: json.RawMessage(`{"conflict_ids": []}`),
|
|
Response: handlers.BulkResolveResponse{},
|
|
},
|
|
|
|
// NOTE: queue param names are :device_id / :item_id in the router —
|
|
// first registry draft used :id and the gate caught it.
|
|
"GET /api/queue/devices/:device_id/stats": {
|
|
Response: handlers.QueueStatsResponse{},
|
|
},
|
|
"GET /api/queue/devices/:device_id/items": {
|
|
Response: json.RawMessage(`{"items": [{}], "count": 0}`),
|
|
},
|
|
"GET /api/queue/items": {
|
|
Response: json.RawMessage(`{"items": [{}], "count": 0}`),
|
|
},
|
|
"POST /api/queue/items/:item_id/retry": {
|
|
Response: json.RawMessage(`{
|
|
"message": "", "item_id": "", "status": "",
|
|
"attempts": 0, "max_attempts": 0
|
|
}`),
|
|
},
|
|
"DELETE /api/queue/items/:item_id": {},
|
|
"DELETE /api/queue/devices/:device_id/clear": {
|
|
Response: json.RawMessage(`{"message": ""}`),
|
|
},
|
|
|
|
// --- Beam batch 3: auth core + devices core ---
|
|
"POST /api/auth/login": {
|
|
Request: handlers.LoginRequest{},
|
|
Response: handlers.AuthResponse{},
|
|
},
|
|
"POST /api/auth/register": {
|
|
Request: handlers.RegisterRequest{},
|
|
Response: handlers.AuthResponse{},
|
|
},
|
|
"POST /api/auth/refresh": {
|
|
Request: handlers.RefreshTokenRequest{},
|
|
Response: handlers.RefreshTokenResponse{},
|
|
},
|
|
"POST /api/auth/logout": {
|
|
Response: json.RawMessage(`{"message": ""}`),
|
|
},
|
|
"GET /api/auth/profile": {
|
|
Response: handlers.UserProfile{},
|
|
},
|
|
"GET /api/devices": {
|
|
Response: handlers.DeviceListResponse{},
|
|
},
|
|
"PUT /api/devices/:id": {
|
|
Request: handlers.DeviceUpdateRequest{},
|
|
Response: json.RawMessage(`{
|
|
"device_updated": false,
|
|
"device": {
|
|
"id": "", "device_name": "", "device_type": "",
|
|
"last_sync": null, "last_seen": null,
|
|
"sync_enabled": false, "auto_sync": false,
|
|
"sync_frequency_minutes": 0, "created_at": "",
|
|
"device_metadata": null, "auth_token": ""
|
|
}
|
|
}`),
|
|
},
|
|
"DELETE /api/devices/:id": {},
|
|
"GET /api/devices/:id/shelves": {
|
|
Response: json.RawMessage(`{
|
|
"status": "", "device_id": "", "shelf_name": "",
|
|
"books": [{}], "total": 0
|
|
}`),
|
|
},
|
|
"POST /api/devices/:id/shelves": {
|
|
Request: handlers.AddToShelfRequest{},
|
|
Response: json.RawMessage(`{
|
|
"status": "", "added_count": 0,
|
|
"shelf_name": "", "total_count": 0
|
|
}`),
|
|
},
|
|
"DELETE /api/devices/:id/shelves": {
|
|
Response: json.RawMessage(`{"status": "", "message": ""}`),
|
|
},
|
|
"DELETE /api/devices/:id/shelves/clear": {
|
|
Response: json.RawMessage(`{"status": "", "message": ""}`),
|
|
},
|
|
"GET /api/devices/:id/sidecar": {
|
|
Response: handlers.SidecarConfig{},
|
|
},
|
|
"POST /api/devices/register": {
|
|
Request: handlers.DeviceRegistrationRequest{},
|
|
Response: handlers.DeviceRegistrationResponse{},
|
|
},
|
|
"POST /api/devices/register/status": {
|
|
Request: handlers.DeviceAuthStatusRequest{},
|
|
Response: handlers.DeviceAuthStatusResponse{},
|
|
},
|
|
|
|
// --- Beam batch 4: media-items core ---
|
|
"GET /api/media-items": {
|
|
Response: json.RawMessage(`{"data": [{}]}`),
|
|
},
|
|
"GET /api/media-items/:id": {
|
|
// Hand-built ~44-key map — pinned verbatim from the handler.
|
|
Response: json.RawMessage(`{
|
|
"id": "", "library_id": "", "title": "", "subtitle": "",
|
|
"author": "", "authors": [], "isbn": "", "description": "",
|
|
"file_path": "", "file_size": 0, "mime_type": "",
|
|
"cover_image_path": "", "series": "", "series_number": 0,
|
|
"tags": [], "asin": "", "date_published": "", "publisher": "",
|
|
"publishers": [], "explicit": false, "contributors": [],
|
|
"language": "", "edition": "", "page_count": 0, "genre": "",
|
|
"created_at": "", "updated_at": "", "format_group": "",
|
|
"format_mimetype": "", "narrators": [], "abridged": false,
|
|
"manga_type": "", "reading_direction": "", "series_count": 0,
|
|
"volume": 0, "imprint": "", "age_rating": "", "web_url": "",
|
|
"metadata_notes": "", "community_rating": 0, "story_arc": "",
|
|
"is_black_and_white": false, "alternate_info": "",
|
|
"scan_information": "", "summary": "", "library_type_name": ""
|
|
}`),
|
|
},
|
|
"POST /api/media-items": {
|
|
Request: handlers.CreateMediaItemRequest{},
|
|
Response: database.MediaItems{},
|
|
},
|
|
"PUT /api/media-items/:id": {
|
|
Request: handlers.UpdateMediaItemRequest{},
|
|
Response: database.MediaItems{},
|
|
},
|
|
"DELETE /api/media-items/:id": {},
|
|
"GET /api/media-items/search": {
|
|
Response: []database.SearchMediaItemsRow{},
|
|
},
|
|
"POST /api/media-items/:id/rating": {
|
|
// NOTE: rating scale is 1-10 int (odd = half-star).
|
|
Request: json.RawMessage(`{"rating": 0}`),
|
|
Response: database.MediaRatings{},
|
|
},
|
|
"PUT /api/media-items/:id/rating": {
|
|
Request: json.RawMessage(`{"rating": 0}`),
|
|
Response: database.MediaRatings{},
|
|
},
|
|
"GET /api/media-items/:id/rating": {
|
|
Response: database.MediaRatings{}, // absent -> {"rating": null}
|
|
},
|
|
"DELETE /api/media-items/:id/rating": {
|
|
Response: json.RawMessage(`{"message": ""}`),
|
|
},
|
|
"GET /api/media-items/:id/progress": {
|
|
Response: json.RawMessage(`{
|
|
"id": null, "media_item_id": null, "user_id": null,
|
|
"current_page": 0, "total_pages": null, "last_read_at": null,
|
|
"percentage": null, "character_offset": null, "epubcfi": null,
|
|
"chapter": null, "chapter_progress": null, "format_group": null,
|
|
"total_characters": null, "chapter_count": null,
|
|
"last_sync_device": null, "last_sync_source": null,
|
|
"last_sync_timestamp": null, "audio_offset_seconds": null,
|
|
"reading_mode": null, "viewport_x": null, "viewport_y": null,
|
|
"zoom_level": null, "scroll_position_x": null,
|
|
"scroll_position_y": null, "panel_number": null,
|
|
"css_selector": null, "anchor_href": null,
|
|
"char_offset": null, "context_text": null
|
|
}`),
|
|
},
|
|
"PUT /api/media-items/:id/progress": {
|
|
Request: sync.SaveProgressRequest{},
|
|
Response: database.ReadingProgress{},
|
|
},
|
|
|
|
// --- Beam batch 5: libraries ---
|
|
"POST /api/libraries": {
|
|
Request: handlers.CreateLibraryRequest{},
|
|
Response: database.Libraries{},
|
|
},
|
|
"GET /api/libraries/:id": {
|
|
Response: database.Libraries{},
|
|
},
|
|
"PUT /api/libraries/:id": {
|
|
Request: handlers.UpdateLibraryRequest{},
|
|
Response: database.Libraries{},
|
|
},
|
|
"DELETE /api/libraries/:id": {},
|
|
"POST /api/libraries/:id/folders": {
|
|
Request: handlers.AddLibraryFolderRequest{},
|
|
Response: database.LibraryFolders{},
|
|
},
|
|
"DELETE /api/libraries/:id/folders": {
|
|
// Handler REUSES AddLibraryFolderRequest ({folder_path}) on delete.
|
|
Request: handlers.AddLibraryFolderRequest{},
|
|
},
|
|
"GET /api/libraries/visibility": {
|
|
Response: []database.GetUserVisibleLibrariesRow{},
|
|
},
|
|
"POST /api/libraries/visibility": {
|
|
Request: handlers.SetLibraryVisibilityRequest{},
|
|
Response: database.LibraryVisibility{},
|
|
},
|
|
"GET /api/libraries/browse": {
|
|
Response: json.RawMessage(`{
|
|
"current_path": "", "parent_path": "", "directories": [{}]
|
|
}`),
|
|
},
|
|
"GET /api/libraries/:id/stats": {
|
|
Response: services.LibraryStats{},
|
|
},
|
|
"GET /api/libraries/:id/media-items": {
|
|
// Delegates to ListMediaItems with library_id injected.
|
|
Response: json.RawMessage(`{"data": [{}]}`),
|
|
},
|
|
|
|
// --- Beam batch 6: kobo/koreader (device-token) + collections missing ---
|
|
"POST /api/sync/kobo/:token/markup": {
|
|
Request: AnyJSONObject{}, // kobo protocol payload (ReadingSync/BookmarkSync)
|
|
Response: handlers.KoboSyncStatus{}, // kobo firmware naming: Status/MarkupsSynced
|
|
},
|
|
"POST /api/sync/kobo/:token/bookmark": {
|
|
Request: AnyJSONObject{}, // kobo bookmark payload
|
|
Response: handlers.KoboSyncStatus{},
|
|
},
|
|
"POST /api/sync/kobo/:token/v1/analytics/gettests": {
|
|
Request: AnyJSONObject{}, // kobo analytics POST body
|
|
// A/B test payload — dynamic keys (tests/features/configuration).
|
|
Response: AnyJSONObject{},
|
|
},
|
|
"GET /api/sync/kobo/:token/v1/initialization": {
|
|
// NOTE: returns the library sync payload, not the init response.
|
|
Response: handlers.KoboLibraryResponse{},
|
|
},
|
|
"POST /api/sync/kobo/:token/sync-from-server": {
|
|
Request: AnyJSONObject{}, // kobo sync POST body
|
|
Response: handlers.KoboServerSyncResponse{},
|
|
},
|
|
"POST /api/sync/koreader/progress": {
|
|
Request: handlers.KOReaderProgressRequest{},
|
|
Response: handlers.KOReaderSyncResponse{},
|
|
},
|
|
"GET /api/sync/koreader/resolve": {
|
|
Response: handlers.KOReaderResolveResponse{},
|
|
},
|
|
"GET /api/sync/koreader/metadata/:uuid": {
|
|
// Hand-built map in the handler.
|
|
Response: AnyJSONObject{},
|
|
},
|
|
"GET /api/sync/koreader/library": {
|
|
Response: handlers.KOReaderLibraryResponse{},
|
|
},
|
|
"POST /api/sync/koreader/bookmarks": {
|
|
Request: AnyJSONObject{}, // {highlights: [...]} inline struct in handler
|
|
Response: AnyJSONObject{}, // hand-built map in the handler
|
|
},
|
|
"PUT /api/collections/:id/items/order": {
|
|
// Inline request {item_ids} — pinned by sample.
|
|
Request: json.RawMessage(`{"item_ids": []}`),
|
|
Response: json.RawMessage(`{"status": "reordered"}`),
|
|
},
|
|
"PUT /api/collections/:id/rules/:ruleId": {
|
|
// Handler is SetRuleEnabled: toggle only, request {enabled required},
|
|
// response {collection_id, is_smart}.
|
|
Request: json.RawMessage(`{"enabled": false}`),
|
|
Response: json.RawMessage(`{
|
|
"collection_id": "", "is_smart": false
|
|
}`),
|
|
},
|
|
|
|
// --- Beam batch 7: scanner ---
|
|
"POST /api/scanner/scan": {
|
|
Request: handlers.ScanLibraryRequest{},
|
|
// 202 Accepted; 409 coalescing variant adds {active_job_id}.
|
|
Response: json.RawMessage(`{
|
|
"message": "", "job_id": "", "status": ""
|
|
}`),
|
|
},
|
|
"POST /api/scanner/start": {
|
|
// Starts the long-lived watch scanner over folder_paths.
|
|
Request: handlers.ScanLibraryRequest{},
|
|
Response: json.RawMessage(`{"message": ""}`),
|
|
},
|
|
"POST /api/scanner/stop": {
|
|
Response: json.RawMessage(`{"message": ""}`),
|
|
},
|
|
"GET /api/scanner/status/:jobId": {
|
|
// result is interface{} (job payload). 404 {error} when unknown job.
|
|
Response: json.RawMessage(`{
|
|
"job_id": "", "status": "", "error": "", "result": null,
|
|
"progress": 0, "files_scanned": 0, "new_items": 0, "errors": 0
|
|
}`),
|
|
},
|
|
"GET /api/scanner/active": {
|
|
// jobs are []JobResult (job_id/status/progress/files_scanned/...).
|
|
Response: json.RawMessage(`{
|
|
"scanning": false, "jobs": []
|
|
}`),
|
|
},
|
|
"GET /api/scanner/active-summary": {
|
|
Response: json.RawMessage(`{
|
|
"scanning": false, "progress": 0
|
|
}`),
|
|
},
|
|
"POST /api/scanner/cancel/:jobId": {
|
|
Response: json.RawMessage(`{
|
|
"message": "", "job_id": "", "status": ""
|
|
}`),
|
|
},
|
|
"POST /api/scanner/watch/start": {
|
|
Request: json.RawMessage(`{"library_id": ""}`),
|
|
Response: json.RawMessage(`{"message": "", "library_id": ""}`),
|
|
},
|
|
"POST /api/scanner/watch/stop": {
|
|
Request: json.RawMessage(`{"library_id": ""}`),
|
|
Response: json.RawMessage(`{"message": "", "library_id": ""}`),
|
|
},
|
|
"GET /api/scanner/watch/status": {
|
|
Response: json.RawMessage(`{
|
|
"watching_libraries": [""], "total_watching": 0
|
|
}`),
|
|
},
|
|
|
|
// --- Beam batch 8: devices remaining + auth remainder ---
|
|
"GET /api/devices/pending": {
|
|
// Device registrations live in an in-memory map; both key spellings
|
|
// are emitted for compatibility.
|
|
Response: json.RawMessage(`{
|
|
"registrations": [], "pending_registrations": [], "total": 0
|
|
}`),
|
|
},
|
|
"GET /api/devices/approve/:registration_id": {
|
|
// Already-approved variant omits registration_id. Credentials ride
|
|
// the registration map polled elsewhere — NOT this JSON body.
|
|
Response: json.RawMessage(`{
|
|
"message": "", "device_name": "", "device_type": "",
|
|
"registration_id": "", "approved": false
|
|
}`),
|
|
},
|
|
"POST /api/devices/reject/:registration_id": {
|
|
Response: json.RawMessage(`{"message": ""}`),
|
|
},
|
|
"POST /api/auth/users": {
|
|
Request: handlers.RegisterRequest{},
|
|
Response: json.RawMessage(`{
|
|
"id": "", "email": "", "username": "", "role": "",
|
|
"timezone": "", "created_at": null
|
|
}`),
|
|
},
|
|
"GET /api/auth/users": {
|
|
// sqlc join row (device_count), NOT database.Users — no password_hash
|
|
// on the wire (live-smoke-verified 2026-10-24).
|
|
Response: []database.ListUsersRow{},
|
|
},
|
|
"PUT /api/auth/users/:id/max-devices": {
|
|
Request: handlers.UpdateUserMaxDevicesRequest{},
|
|
Response: json.RawMessage(`{"message": ""}`),
|
|
},
|
|
"PUT /api/auth/password": {
|
|
// Self-change (jwt). Admin variant PUT /auth/password/:id documented
|
|
// in prose; handler is shared (admin mode skips current_password).
|
|
Request: json.RawMessage(`{
|
|
"current_password": "", "new_password": "", "confirm_password": ""
|
|
}`),
|
|
Response: json.RawMessage(`{"message": ""}`),
|
|
},
|
|
"PUT /api/auth/profile": {
|
|
// Shared with admin variant (PUT /auth/profile/:id).
|
|
Request: handlers.ProfileUpdateRequest{},
|
|
Response: json.RawMessage(`{"message": ""}`),
|
|
},
|
|
"DELETE /api/auth/profile": {
|
|
// Self-delete; admin variant DELETE /auth/profile/:id (reassignment
|
|
// of admin-owned assets to another admin before cascade).
|
|
Response: json.RawMessage(`{"message": ""}`),
|
|
},
|
|
"GET /api/progress/:id/history": {
|
|
// id is MEDIA ITEM id; sessions are ReadingHistory rows.
|
|
Response: json.RawMessage(`{
|
|
"media_item_id": "", "sessions": [], "count": 0
|
|
}`),
|
|
},
|
|
"GET /api/books/match": {
|
|
Response: services.BookQueryResponse{},
|
|
},
|
|
"GET /api/auth/password-rules": {
|
|
Response: json.RawMessage(`{
|
|
"min_length": 8, "require_upper": true, "require_lower": true,
|
|
"require_number": true, "require_special": true
|
|
}`),
|
|
},
|
|
|
|
// --- Beam batch 9: highlights, notes, progress delete, analytics, opds ---
|
|
"POST /api/media-items/:id/highlights": {
|
|
Request: handlers.CreateMediaHighlightRequest{},
|
|
Response: database.MediaHighlights{},
|
|
},
|
|
"GET /api/media-items/:id/highlights": {
|
|
Response: []database.MediaHighlights{},
|
|
},
|
|
"PUT /api/media-items/:id/highlights/:highlightId": {
|
|
// LWW edit targeted by URL id (identity NOT re-derived from content).
|
|
Request: handlers.UpdateMediaHighlightRequest{},
|
|
Response: database.MediaHighlights{},
|
|
},
|
|
"DELETE /api/media-items/:id/highlights/:highlightId": {
|
|
// Tombstone via sync service; 204 No Content.
|
|
},
|
|
"POST /api/media-items/:id/notes": {
|
|
Request: handlers.CreateMediaNoteRequest{},
|
|
Response: database.MediaNotes{},
|
|
},
|
|
"GET /api/media-items/:id/notes": {
|
|
Response: []database.MediaNotes{},
|
|
},
|
|
"PUT /api/media-items/:id/notes/:noteId": {
|
|
Request: handlers.UpdateMediaNoteRequest{},
|
|
Response: database.MediaNotes{},
|
|
},
|
|
"DELETE /api/media-items/:id/notes/:noteId": {
|
|
// Tombstone; 204 No Content.
|
|
},
|
|
"DELETE /api/media-items/:id/progress": {
|
|
Response: json.RawMessage(`{"message": ""}`),
|
|
},
|
|
"GET /api/analytics/reading-stats": {
|
|
// Query: start_date/end_date in MM-DD-YYYY, user-timezone buckets.
|
|
Response: handlers.ReadingStatsResponse{},
|
|
},
|
|
|
|
// OPDS: XML/Atom responses (content negotiated, not JSON) — response
|
|
// side intentionally unpinned.
|
|
"GET /opds/devices/:deviceId/catalog": {},
|
|
"GET /opds/devices/:deviceId/download/:bookId": {},
|
|
"GET /opds/devices/:deviceId/cover/:bookId": {},
|
|
|
|
// --- Beam batch 10: book-matching + collections remainder ---
|
|
"POST /api/sync/books/query": {
|
|
// Plugin/sync-protocol variant of GET /api/books/match (body, not
|
|
// query params; unfiltered legacy path).
|
|
Request: services.BookQueryRequest{},
|
|
Response: services.BookQueryResponse{},
|
|
},
|
|
"POST /api/sync/bulk-link-books": {
|
|
Request: handlers.BulkLinkBooksRequest{},
|
|
Response: json.RawMessage(`{
|
|
"results": [], "total": 0, "successful": 0, "failed": 0
|
|
}`),
|
|
},
|
|
"POST /api/sync/auto-link-books": {
|
|
Request: handlers.AutoLinkBooksRequest{},
|
|
Response: json.RawMessage(`{
|
|
"auto_linked": 0, "results": []
|
|
}`),
|
|
},
|
|
"GET /api/devices/:deviceId/sync/unlinked-books": {
|
|
Response: json.RawMessage(`{
|
|
"unlinked": [], "total": 0
|
|
}`),
|
|
},
|
|
"GET /api/sync/unlinked-books/:id/suggestions": {
|
|
Response: json.RawMessage(`{
|
|
"unlinked_book_id": "", "title_from_device": "", "sha256": "",
|
|
"suggestions": [], "total_suggestions": 0, "action": ""
|
|
}`),
|
|
},
|
|
"GET /api/devices/:id/file-aliases": {
|
|
Response: json.RawMessage(`{
|
|
"device_id": "", "aliases": [], "total": 0
|
|
}`),
|
|
},
|
|
"POST /api/devices/:id/file-aliases": {
|
|
// Inline request struct in handler.
|
|
Request: json.RawMessage(`{
|
|
"media_item_id": "", "file_path": "", "file_sha256": "",
|
|
"confidence_score": 0
|
|
}`),
|
|
Response: json.RawMessage(`{
|
|
"id": "", "media_item_id": "", "device_id": "", "file_path": "",
|
|
"file_sha256": "", "confidence_score": 0, "last_seen_at": ""
|
|
}`),
|
|
},
|
|
"PUT /api/devices/:id/file-aliases/:aliasId": {
|
|
// Partial update — pointer fields.
|
|
Request: json.RawMessage(`{
|
|
"media_item_id": "", "file_sha256": "", "confidence_score": 0
|
|
}`),
|
|
Response: json.RawMessage(`{
|
|
"id": "", "media_item_id": "", "file_path": "",
|
|
"file_sha256": "", "confidence_score": 0, "last_seen_at": ""
|
|
}`),
|
|
},
|
|
"DELETE /api/devices/:id/file-aliases/:aliasId": {
|
|
Response: json.RawMessage(`{"message": ""}`),
|
|
},
|
|
"POST /api/collections/:id/rules": {
|
|
// First rule converts a static collection to smart.
|
|
Request: handlers.AddRuleRequest{},
|
|
Response: json.RawMessage(`{
|
|
"collection_id": "", "rule": {}, "is_smart": false
|
|
}`),
|
|
},
|
|
"DELETE /api/collections/:id/rules/:ruleId": {
|
|
Response: json.RawMessage(`{
|
|
"collection_id": "", "is_smart": false
|
|
}`),
|
|
},
|
|
"POST /api/collections/test-rules": {
|
|
Request: handlers.TestRulesRequest{},
|
|
Response: json.RawMessage(`{"matches": [], "total": 0}`),
|
|
},
|
|
"POST /api/collections/:id/books": {
|
|
// Bulk add: {operations: [{collection_id, book_ids:[...]}]}.
|
|
Request: json.RawMessage(`{
|
|
"operations": [{"collection_id": "", "book_ids": []}]
|
|
}`),
|
|
Response: json.RawMessage(`{"results": [], "total": 0}`),
|
|
},
|
|
"POST /api/devices/:id/collections": {
|
|
Request: handlers.CreateDeviceMappingRequest{},
|
|
Response: json.RawMessage(`{
|
|
"id": "", "collection_id": "", "device_id": "",
|
|
"device_shelf_name": "", "sync_direction": "", "created_at": ""
|
|
}`),
|
|
},
|
|
|
|
// --- Beam batch 11: final backfill ---
|
|
"GET /api/collections/:id/books": {
|
|
// Reverse listing: which collections carry this book.
|
|
Response: json.RawMessage(`{
|
|
"book_id": "", "collections": [], "total": 0
|
|
}`),
|
|
},
|
|
"POST /api/collections/:id/books/bulk-remove": {
|
|
Request: handlers.BulkRemoveBooksRequest{},
|
|
Response: json.RawMessage(`{"removed": 0, "total": 0}`),
|
|
},
|
|
"POST /api/collections/bulk-add-books": {
|
|
Request: json.RawMessage(`{
|
|
"operations": [{"collection_id": "", "book_ids": []}]
|
|
}`),
|
|
Response: json.RawMessage(`{"results": [], "total": 0}`),
|
|
},
|
|
"POST /api/collections/preview": {
|
|
// Smart-collection dry run — inline request struct.
|
|
Request: json.RawMessage(`{
|
|
"library_id": "", "rules": [], "manual_book_ids": [], "limit": 0
|
|
}`),
|
|
Response: json.RawMessage(`{"items": []}`),
|
|
},
|
|
"DELETE /api/devices/:id/collections/:collectionId": {
|
|
// 204 No Content.
|
|
},
|
|
"POST /api/media-items/bulk-delete": {
|
|
Request: json.RawMessage(`{"media_item_ids": []}`),
|
|
Response: json.RawMessage(`{
|
|
"results": [], "total": 0, "deleted": 0, "failed": 0
|
|
}`),
|
|
},
|
|
"POST /api/media-items/bulk-update": {
|
|
// updates fields are pointers (partial per-item patch).
|
|
Request: json.RawMessage(`{
|
|
"media_item_updates": [
|
|
{
|
|
"media_item_id": "",
|
|
"updates": {
|
|
"title": "", "author": "", "genre": "",
|
|
"language": "", "tags": []
|
|
}
|
|
}
|
|
]
|
|
}`),
|
|
Response: json.RawMessage(`{
|
|
"results": [], "total": 0, "updated": 0, "failed": 0
|
|
}`),
|
|
},
|
|
"GET /api/media-items/:id/annotations/deleted": {
|
|
Response: json.RawMessage(`{
|
|
"deleted_annotations": [], "total": 0
|
|
}`),
|
|
},
|
|
"GET /api/media-items/:id/chapters": {
|
|
Response: json.RawMessage(`{
|
|
"media_item_id": "", "chapters": [], "duration_seconds": 0,
|
|
"codec_profile": {}, "layout": "", "tracks": []
|
|
}`),
|
|
},
|
|
"GET /api/media-items/:id/audio/:trackIndex": {
|
|
// Raw audio bytes via http.ServeContent (Range support) — not JSON.
|
|
},
|
|
"GET /api/sync/koreader/rating": {
|
|
// Book identity via query params (uuid/sha256/title/authors).
|
|
Response: json.RawMessage(`{"rating": null}`),
|
|
},
|
|
"POST /api/sync/koreader/rating": {
|
|
// koreaderBookRef embedded + rating 0-10 (odd = half-star).
|
|
Request: json.RawMessage(`{
|
|
"uuid": "", "sha256": "", "title": "", "authors": [], "rating": 0
|
|
}`),
|
|
Response: json.RawMessage(`{"rating": null}`),
|
|
},
|
|
"GET /api/sync/koreader/review": {
|
|
Response: json.RawMessage(`{"review": null}`),
|
|
},
|
|
"POST /api/sync/koreader/review": {
|
|
Request: json.RawMessage(`{
|
|
"uuid": "", "sha256": "", "title": "", "authors": [], "review": ""
|
|
}`),
|
|
Response: json.RawMessage(`{"review": null}`),
|
|
},
|
|
"POST /api/sync/koreader/stats": {
|
|
Request: json.RawMessage(`{
|
|
"uuid": "", "sha256": "", "title": "", "authors": [],
|
|
"total_seconds": 0
|
|
}`),
|
|
Response: AnyJSONObject{}, // hand-built result map
|
|
},
|
|
"GET /api/admin/hash-conflicts": {
|
|
// JSON list endpoint (admin). The resolve POST is an htmx fragment
|
|
// renderer — deliberately NOT documented (rule 3).
|
|
Response: json.RawMessage(`{"conflicts": [], "total": 0}`),
|
|
},
|
|
|
|
// --- Claude: stats (service-backed reading statistics) ---
|
|
"GET /api/stats/summary": {
|
|
// ?range=7|30|90|year; zero-filled daily arrays; user-tz buckets.
|
|
Response: services.UserSummaryStats{},
|
|
},
|
|
"GET /api/stats/this-week": {
|
|
// ?library_id= optional (visible-set enforced like library-usage? no —
|
|
// this route passes it straight through).
|
|
Response: services.WeekSummaryStats{},
|
|
},
|
|
"GET /api/stats/library-usage": {
|
|
// ?library_id= optional; explicit id OUTSIDE the visible set → 403.
|
|
// Inventory aggregates (media_items), no reading_history.
|
|
Response: services.LibraryUsageStats{},
|
|
},
|
|
"GET /api/stats/books/:id": {
|
|
// Lifetime totals + recent sessions for one media item.
|
|
Response: services.BookStats{},
|
|
},
|
|
}
|