Cover the admin API added in 03cb4c7 for duplicate-content decisions:
- GET /api/admin/hash-conflicts — pending conflict groups with member
items and per-item usage counts (progress, highlights, bookmarks,
notes, collections)
- POST /api/admin/hash-conflicts/:id/resolve — action=keep (merge child
rows into keep_uuid, delete losers) vs action=keep_all (dismiss);
JSON and form-encoded bodies, error codes including 409 for already
resolved
- When conflicts are created (startup backfill, rescans) and the
guarantee that files on disk are never deleted
3.6 KiB
Hash Conflicts API
Overview
When Bookhoard hashes your library (on import, rescan, or the startup backfill), two media items in the same library with the same file_sha256 indicate duplicate content. Each duplicate group is recorded as a hash conflict and exposed here for an explicit keep/merge decision. Conflicts are also surfaced in the admin UI's Hash Conflicts page.
Authentication: Admin JWT token required
Content-Type: application/json (resolve also accepts form-encoded bodies for htmx)
Endpoints
List Hash Conflicts
List all pending conflict groups, each with its member items and per-item usage counts (reading progress, highlights, bookmarks, notes, collections) to help decide which copy to keep.
Endpoint: GET /api/admin/hash-conflicts
Response: 200 OK
{
"conflicts": [
{
"id": "conflict-uuid",
"library_id": "library-uuid",
"library_name": "Ebooks",
"sha256": "abc123...",
"created_at": "2026-08-14T12:00:00Z",
"items": [
{
"id": "media-item-uuid",
"title": "The Hobbit",
"author": "J. R. R. Tolkien",
"file_path": "/books/hobbit.epub",
"file_size": 1048576,
"created_at": "2026-01-01T00:00:00Z",
"progress_count": 2,
"highlight_count": 12,
"bookmark_count": 3,
"note_count": 1,
"collection_count": 2
}
]
}
],
"total": 1
}
Example:
curl -X GET https://bookhoard.example.com/api/admin/hash-conflicts \
-H "Authorization: Bearer <admin_token>"
Resolve Hash Conflict
Resolve one conflict group.
Endpoint: POST /api/admin/hash-conflicts/{id}/resolve
Request Body (JSON or form-encoded):
{
"action": "keep",
"keep_uuid": "media-item-uuid-to-keep"
}
| Field | Type | Required | Description |
|---|---|---|---|
action |
string | Yes | keep_all — both copies are intentional; dismiss the conflict. keep — keep keep_uuid and delete the other copies. |
keep_uuid |
string | for action=keep |
The media item UUID to keep. Must belong to this conflict group. With keep, every other copy's child rows (progress, highlights, bookmarks, notes, collections, …) are merged into the kept item before the losers are deleted. |
Responses:
200 OK— resolved (body is an HTML confirmation snippet for the admin UI page)400 Bad Request— invalid conflict ID, missingkeep_uuid, orkeep_uuidnot in the group404 Not Found— conflict doesn't exist409 Conflict— conflict already resolved
Example:
curl -X POST https://bookhoard.example.com/api/admin/hash-conflicts/<id>/resolve \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{"action": "keep", "keep_uuid": "media-item-uuid"}'
When Conflicts Are Created
- Startup backfill: items imported before hashing existed are hashed automatically ~30s after startup; duplicates discovered land here.
- Rescan: hashes are recomputed and content duplicates are flagged.
Files on disk are never deleted — resolution only affects database rows.
Related Endpoints
- System Settings API — scanning configuration
- Scanner API — triggering scans and watch mode