docs(media-items): Work Order 3 batch 3b — management endpoints (8)

New frontmatter'd docs:
- download (ServeFile — raw bytes, family-lens enforced; corrects the old
  download doc's claim that no download route is registered: ServeFile IS
  routed, DownloadBook is the dead code)
- set_audio_speed (speed range 0.25-4.0, NaN/Inf rejected)
- rescan (abridged example over the full 70+-column row; write-after-success
  guarantee noted)
- purge-archived (destructive, admin), unarchive (204), merge (SHA-256
  precondition, per-user collisions keep active copy)
- facets (two modes: facet+values, field autocomplete counts)
- filter_count (family lens)

Registry: RawMessage pins for map/abridged responses; Schema{} for non-JSON
body routes (download = raw bytes; unarchive = 204). Audit now:
missing docs down to ~55 (from 112), 0 orphans.
This commit is contained in:
John O'Keefe committed 2026-10-24 15:06:23 -04:00
1 parent ed0ce5d077
commit 4a6fb033cb
9 files changed
+307

No files matched your search

@@ -0,0 +1,26 @@
---
method: GET
path: /api/media-items/:id/download
auth: jwt
area: media-items
---
<!-- source: internal/handlers/media.go:2766 MediaHandler.ServeFile -->
# Download Media Item File
Serves the book file itself (epub, pdf, …) for the given media item, enforced
against the requester's **visible-library set** (family lens). This is the
registered route — distinct from the dead `MediaHandler.DownloadBook` code
path referenced by pre-2026 docs.
Success is the raw file bytes with the file's MIME type. There is no JSON
body.
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "invalid library ID"}` / `{"error": "invalid path"}` / `{"error": "invalid media item ID"}` | malformed ids |
| 403 | `{"error": "library not accessible"}` | library outside the visible set |
| 404 | `{"error": "book not found"}` / `{"error": "book file not found"}` / `{"error": "file not found"}` | missing row or file on disk |
| 500 | `{"error": "failed to check library access"}` | unexpected server error |
@@ -0,0 +1,33 @@
---
method: GET
path: /api/media-items/filter-count
auth: jwt
area: media-items
---
<!-- source: internal/handlers/media.go:2713 MediaHandler.FilterCount -->
# Filter Count
Returns how many media items the current filters match, scoped to the
requester's visible libraries. Used by the filter UI's live count.
## Example response
```json example-response
{
"count": 42
}
```
## Query parameters
UNVERIFIED: filter parameters mirror the media-items list endpoint (same
filter machinery); pass them through unchanged.
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "..."}` | invalid filter params |
| 403 | `{"error": "library not visible"}` | family lens |
| 500 | `{"error": "..."}` | unexpected server error |
@@ -0,0 +1,48 @@
---
method: GET
path: /api/media-items/facets
auth: jwt
area: media-items
---
<!-- source: internal/handlers/media.go:2532 MediaHandler.GetFacets -->
# Get Media Item Facets
Two modes, both returning small JSON objects:
1. **Facet values** (primary): `?facet=<param>&library_id=<uuid>` — lists the
distinct values of a facet within one visible library.
2. **Autocomplete counts**: `?field=<key>&library_id=<uuid>` — returns
`{"facets": {...}}` value counts for dropdowns.
## Example response
```json example-response
{
"facet": "author",
"values": [
{
"value": "George Orwell"
}
]
}
```
UNVERIFIED: exact element shape of `values` (service-returned rows).
## Query parameters
| Param | Type | Notes |
| ----- | ---- | ----- |
| facet | string | facet param name; requires `library_id` |
| field | string | registry field key (autocomplete mode) |
| library_id | uuid | required for facet mode; must be visible to the requester |
| limit | number | caps facet values |
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "library_id is required"}` / `{"error": "unknown facet: ..."}` / `{"error": "facet does not list values: ..."}` / `{"error": "invalid library_id"}` | bad params |
| 403 | `{"error": "library not visible"}` | family lens |
| 500 | `{"error": "..."}` | unexpected server error |
@@ -0,0 +1,40 @@
---
method: POST
path: /api/media-items/:id/merge
auth: jwt+admin
area: media-items
---
<!-- source: internal/handlers/media.go:1674 MediaHandler.MergeArchivedMediaItem -->
# Merge Archived Media Item
**Admin only.** Merges an archived/missing item's reading data (progress,
annotations, highlights, notes, bookmarks, ratings, collections, Kobo
shelves) into an active item with identical content (same SHA-256), then
deletes the archived row. Covers the copy-then-delete-later workflow. Per-user
collisions keep the active copy's data (mirrors hash-conflict resolution).
## Example request
```json example-request
{
"target_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
## Example response
```json example-response
{
"message": "Merged reading progress, annotations, and collections into the active item"
}
```
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "source item is not archived or missing"}` / `{"error": "target item is not active"}` / `{"error": "items do not share the same content hash"}` | precondition violations |
| 403 | `{"error": "admin access required"}` | not an admin |
| 404 | `{"error": "archived item not found"}` / `{"error": "target item not found"}` | missing rows |
| 500 | `{"error": "failed to merge reading data: ..."}` | unexpected server error |
@@ -0,0 +1,29 @@
---
method: POST
path: /api/media-items/purge-archived
auth: jwt+admin
area: media-items
---
<!-- source: internal/handlers/media.go:1747 MediaHandler.PurgeArchivedMediaItems -->
# Purge Archived Media Items
**Admin only.** Permanently deletes ALL archived media items and their
cover files. This is destructive — archived rows hold no reading data merge
path (use [Merge Archived Media Item](merge_archived_media_item.md) per item
first if reading history should survive).
## Example response
```json example-response
{
"purged": 3
}
```
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 403 | `{"error": "admin access required"}` | not an admin |
| 500 | `{"error": "..."}` | unexpected server error |
@@ -0,0 +1,43 @@
---
method: POST
path: /api/media-items/:id/rescan
auth: jwt+admin
area: media-items
---
<!-- source: internal/handlers/media.go:1765 MediaHandler.RescanMediaItem -->
# Rescan Media Item
Re-extracts metadata for one media item from its file and returns the updated
row (200, full `media_items` record — 70+ columns; the most relevant are shown
below).
Write-after-success guarantee: a failed/empty extraction never clears
scan-derived columns.
## Example response (abridged)
```json example-response
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"library_id": "990e8400-e29b-41d4-a716-446655440000",
"title": "1984",
"author": "George Orwell",
"file_path": "/books/1984.epub",
"mime_type": "application/epub+zip",
"scan_status": "completed",
"updated_at": "2026-10-24T14:57:01Z"
}
```
The example is abridged for readability — the response carries the full
`media_items` row.
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "invalid media item id"}` | malformed id |
| 403 | `{"error": "admin access required"}` | not an admin |
| 404 | `{"error": "media item not found"}` | no such item |
| 500 | `{"error": "..."}` | extraction/DB failure |
@@ -0,0 +1,37 @@
---
method: PUT
path: /api/media-items/:id/audio-speed
auth: jwt
area: media-items
---
<!-- source: internal/handlers/media.go:3033 MediaHandler.SetAudioSpeed -->
# Set Audio Speed
Persists the audiobook playback speed for the signed-in user on one media
item. Speed must be within **0.25–4.0** (NaN/Inf rejected).
## Example request
```json example-request
{
"speed": 1.5
}
```
## Example response
```json example-response
{
"playback_speed": 1.5
}
```
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "invalid media item id"}` / `{"error": "invalid request"}` / `{"error": "speed out of range"}` | malformed or out-of-range |
| 401 | `{"error": "authentication required"}` / `{"error": "invalid user"}` | no/invalid auth |
| 404 | `{"error": "book not found"}` | no such media item |
| 500 | `{"error": "failed to save playback speed"}` | unexpected server error |
@@ -0,0 +1,20 @@
---
method: POST
path: /api/media-items/:id/unarchive
auth: jwt+admin
area: media-items
---
<!-- source: internal/handlers/media.go:1646 MediaHandler.UnarchiveMediaItem -->
# Unarchive Media Item
**Admin only.** Clears the archive flag from a media item so it re-enters the
active library. Success is **204 No Content** — no body.
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "invalid media item id"}` | malformed id |
| 403 | `{"error": "admin access required"}` | not an admin |
| 500 | `{"error": "..."}` | unexpected server error |
+31
View File
@@ -207,4 +207,35 @@ var Schemas = map[string]Schema{
Request: nil, // kind via required ?annotation_type= Request: nil, // kind via required ?annotation_type=
Response: json.RawMessage(`{"purged": false}`), 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}`),
},
} }