diff --git a/docs/developer/api/media-items/download_media_item_file.md b/docs/developer/api/media-items/download_media_item_file.md
new file mode 100644
index 00000000..67ecaf30
--- /dev/null
+++ b/docs/developer/api/media-items/download_media_item_file.md
@@ -0,0 +1,26 @@
+---
+method: GET
+path: /api/media-items/:id/download
+auth: jwt
+area: media-items
+---
+
+
+# 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 |
diff --git a/docs/developer/api/media-items/filter_count.md b/docs/developer/api/media-items/filter_count.md
new file mode 100644
index 00000000..873cc9b5
--- /dev/null
+++ b/docs/developer/api/media-items/filter_count.md
@@ -0,0 +1,33 @@
+---
+method: GET
+path: /api/media-items/filter-count
+auth: jwt
+area: media-items
+---
+
+
+# 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 |
diff --git a/docs/developer/api/media-items/get_facets.md b/docs/developer/api/media-items/get_facets.md
new file mode 100644
index 00000000..9837e07a
--- /dev/null
+++ b/docs/developer/api/media-items/get_facets.md
@@ -0,0 +1,48 @@
+---
+method: GET
+path: /api/media-items/facets
+auth: jwt
+area: media-items
+---
+
+
+# Get Media Item Facets
+
+Two modes, both returning small JSON objects:
+
+1. **Facet values** (primary): `?facet=&library_id=` — lists the
+ distinct values of a facet within one visible library.
+2. **Autocomplete counts**: `?field=&library_id=` — 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 |
diff --git a/docs/developer/api/media-items/merge_archived_media_item.md b/docs/developer/api/media-items/merge_archived_media_item.md
new file mode 100644
index 00000000..f0c65395
--- /dev/null
+++ b/docs/developer/api/media-items/merge_archived_media_item.md
@@ -0,0 +1,40 @@
+---
+method: POST
+path: /api/media-items/:id/merge
+auth: jwt+admin
+area: media-items
+---
+
+
+# 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 |
diff --git a/docs/developer/api/media-items/purge_archived_media_items.md b/docs/developer/api/media-items/purge_archived_media_items.md
new file mode 100644
index 00000000..bfcd67f6
--- /dev/null
+++ b/docs/developer/api/media-items/purge_archived_media_items.md
@@ -0,0 +1,29 @@
+---
+method: POST
+path: /api/media-items/purge-archived
+auth: jwt+admin
+area: media-items
+---
+
+
+# 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 |
diff --git a/docs/developer/api/media-items/rescan_media_item.md b/docs/developer/api/media-items/rescan_media_item.md
new file mode 100644
index 00000000..f42a45bc
--- /dev/null
+++ b/docs/developer/api/media-items/rescan_media_item.md
@@ -0,0 +1,43 @@
+---
+method: POST
+path: /api/media-items/:id/rescan
+auth: jwt+admin
+area: media-items
+---
+
+
+# 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 |
diff --git a/docs/developer/api/media-items/set_audio_speed.md b/docs/developer/api/media-items/set_audio_speed.md
new file mode 100644
index 00000000..d353ceea
--- /dev/null
+++ b/docs/developer/api/media-items/set_audio_speed.md
@@ -0,0 +1,37 @@
+---
+method: PUT
+path: /api/media-items/:id/audio-speed
+auth: jwt
+area: media-items
+---
+
+
+# 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 |
diff --git a/docs/developer/api/media-items/unarchive_media_item.md b/docs/developer/api/media-items/unarchive_media_item.md
new file mode 100644
index 00000000..86aaebbc
--- /dev/null
+++ b/docs/developer/api/media-items/unarchive_media_item.md
@@ -0,0 +1,20 @@
+---
+method: POST
+path: /api/media-items/:id/unarchive
+auth: jwt+admin
+area: media-items
+---
+
+
+# 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 |
diff --git a/internal/docs/schema.go b/internal/docs/schema.go
index 00e91d47..1e75d3c7 100644
--- a/internal/docs/schema.go
+++ b/internal/docs/schema.go
@@ -207,4 +207,35 @@ var Schemas = map[string]Schema{
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}`),
+ },
}