Compare commits
9
Commits
e2953c4a01
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1eda696f1 | ||
|
|
27b3dcb69f | ||
|
|
5c5593644d | ||
|
|
9e516b96cc | ||
|
|
44735554f7 | ||
|
|
311049379d | ||
|
|
f65db5ab4f | ||
|
|
7ddcdd0756 | ||
|
|
b927ed9988 |
@@ -1,4 +1,4 @@
|
||||
# 📚 Bookhoard
|
||||
# <img src="web/static/favicon.svg" width="32" alt="Bookhoard logo"> Bookhoard
|
||||
|
||||
A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and Tailwind CSS featuring **universal cross-device sync**, beautiful dark themes, and comprehensive media management.
|
||||
|
||||
@@ -111,6 +111,7 @@ The first user to register automatically becomes an admin.
|
||||
### For Developers
|
||||
|
||||
- **[docs/developer/api/api-reference.md](docs/developer/api/api-reference.md)** - Complete API documentation
|
||||
- **[docs/developer/android-app.md](docs/developer/android-app.md)** - Android app design & roadmap
|
||||
- **[docs/contributing/development.md](docs/contributing/development.md)** - Development workflow
|
||||
|
||||
---
|
||||
@@ -122,7 +123,7 @@ The first user to register automatically becomes an admin.
|
||||
| **Web Browser** | ✅ | ✅ | Full support |
|
||||
| **KOReader** | ✅ | ✅ | Runs on Kindle, Kobo, PocketBook hardware |
|
||||
| **Kobo Devices** | 🚧 | 🚧 | Native Kobo sync coming soon (use KOReader on Kobo today) |
|
||||
| **Mobile Apps** | 🚧 | 🚧 | Android/iOS apps coming later |
|
||||
| **Mobile Apps** | 🚧 | 🚧 | Native Android app in design ([docs](docs/developer/android-app.md)); iOS later |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -335,8 +335,8 @@ CREATE TABLE IF NOT EXISTS media_highlights (
|
||||
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
selection_text TEXT NOT NULL,
|
||||
start_position VARCHAR(100), -- position (page:offset or CFI) where highlight starts
|
||||
end_position VARCHAR(100), -- position (page:offset or CFI) where highlight ends
|
||||
start_position TEXT, -- position (page:offset, CFI, or locator JSON) where highlight starts
|
||||
end_position TEXT, -- position (page:offset, CFI, or locator JSON) where highlight ends
|
||||
color VARCHAR(7) DEFAULT '#ffff00', -- hex color code for highlight
|
||||
note_id UUID REFERENCES media_notes(id) ON DELETE SET NULL, -- optional associated note
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
@@ -1364,6 +1364,13 @@ ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS note_text TEXT;
|
||||
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- Widen position columns for existing databases: the API handlers
|
||||
-- validate up to 1000 characters (full Readium locators, KOReader CRE
|
||||
-- xpointers) but VARCHAR(100) rejected anything longer at the database
|
||||
-- layer. VARCHAR -> TEXT is a metadata-only change, safe to re-run.
|
||||
ALTER TABLE media_highlights ALTER COLUMN start_position TYPE TEXT;
|
||||
ALTER TABLE media_highlights ALTER COLUMN end_position TYPE TEXT;
|
||||
|
||||
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
||||
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
||||
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# Android App — Design & Roadmap
|
||||
|
||||
This document describes the planned native Android client for Bookhoard: a thin, offline-first reading app that treats the Bookhoard server as its backend. The relationship is the same as the audiobookshelf app to an audiobookshelf server, or the Kindle app to Kindle cloud — the server owns the library, sync, and conflict resolution; the app is a dedicated, mobile-first reading frontend with its own UI, designed independently of the web interface.
|
||||
|
||||
**Status**: Planning / pre-development
|
||||
**Companion repo**: `bookhoard-app` (separate repository, AGPL-3.0)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Product Vision
|
||||
|
||||
- An **amazing ereader** first and foremost — rendering polish, latency, and reading UX are the product
|
||||
- **Mobile-first UI** designed from scratch for phones; not a wrapper around the web app
|
||||
- **Thin client**: the server remains authoritative for all sync, book matching, and conflict resolution
|
||||
- **v1 formats**: EPUB (ebooks) and CBZ (comics/manga); PDF comes nearly free via the reader toolkit
|
||||
- **Android first**. iOS is a real roadmap item but unscheduled — likely contributor-driven
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Tech Stack
|
||||
|
||||
| Concern | Choice |
|
||||
| -------------- | ------------------------------------------------- |
|
||||
| Language | Kotlin |
|
||||
| UI | Jetpack Compose + Material 3 |
|
||||
| Reader engine | [Readium Kotlin toolkit](https://github.com/readium/kotlin-toolkit) |
|
||||
| Local database | Room |
|
||||
| Networking | OkHttp / Retrofit + WebSocket |
|
||||
| Background | WorkManager |
|
||||
| Images | Coil |
|
||||
| Settings | DataStore |
|
||||
|
||||
### Why native Android
|
||||
|
||||
- The quality bar is the Kindle app. Page-turn latency, text layout fidelity, PDF rendering (`PdfRenderer`), and comic/manga image pipelines are platform-level strengths — and they are the *hard* parts in a WebView, not the easy parts.
|
||||
- Android-first removes the "share one codebase across two platforms simultaneously" constraint that motivates hybrid stacks.
|
||||
- Solo, AI-assisted development compresses the cost of native (code volume), while native's failure modes (well-documented platform APIs) are far easier to debug — alone or with AI — than cross-framework bridge/plugin bugs.
|
||||
- The target audience is the self-hosted community, best reached via GitHub Releases and F-Droid rather than app-store optimization.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- **Capacitor / WebView shell** (the audiobookshelf-app model): excellent when a self-contained SPA already exists; a poor fit here. Bookhoard's web UI is server-rendered HTMX and cannot be packaged, comics rendering in a WebView caps the polish target, and deep offline support fights the shell.
|
||||
- **Flutter**: strong middle ground, but no Readium port and a weaker EPUB/PDF plugin ecosystem than the native toolkits.
|
||||
- **Kotlin Multiplatform**: only pays off with a committed near-term iOS effort. Revisit if iOS becomes active; until then it would constrain v1 for a hypothetical.
|
||||
|
||||
---
|
||||
|
||||
## 🏗 Architecture
|
||||
|
||||
Thin, offline-first client. The server API is the contract (see [API Reference](api/api-reference.md) and [WebSocket API](websocket-api.md)).
|
||||
|
||||
### Module layout
|
||||
|
||||
```
|
||||
:app Compose UI, navigation, dependency injection
|
||||
:core:domain Pure Kotlin — models, sync logic, use cases (no Android deps)
|
||||
:core:data Room, Retrofit/OkHttp, downloads and file storage
|
||||
:feature:reader Readium navigator integration and reading UI
|
||||
```
|
||||
|
||||
Keeping `:core:domain` free of Android dependencies preserves optionality: a future iOS client, a KMP extraction, or a desktop client can reuse or port the domain logic without touching the UI.
|
||||
|
||||
### Offline-first sync flow
|
||||
|
||||
1. UI writes go to the local Room mirror **first** (never blocked on network)
|
||||
2. A WorkManager queue replays changes to the existing REST endpoints (`/api/progress`, `/api/media-items/:id/notes`, `/highlights`, etc.)
|
||||
3. Conflicts are resolved by the server's existing mechanisms — the client never invents its own merge logic
|
||||
4. While online, a WebSocket connection receives realtime updates pushed by other devices (web reader, KOReader)
|
||||
5. Books are downloaded to app storage for fully offline reading, with storage management UI
|
||||
|
||||
### Authentication & device identity
|
||||
|
||||
- **Primary auth: username/password login** via the existing endpoints (`POST /api/auth/login` + refresh). The app is a full user client — browse, collections, ratings, and annotation management all live behind the user JWT, which device tokens cannot reach
|
||||
- After login, the app registers itself as a **device** (`device_type: mobile`) and **self-approves** its registration using its own JWT — approval only requires a logged-in user. The phone then appears on the Devices page with sync attribution, per-device settings, and individually revocable access, with no QR ceremony
|
||||
- Netflix-style QR pairing as a zero-typing sign-in option: post-v1 (see below)
|
||||
|
||||
---
|
||||
|
||||
## 📖 Reader Engine
|
||||
|
||||
[Readium](https://github.com/readium/kotlin-toolkit) provides EPUB, PDF, and CBZ through one publication model and navigator — production-hardened by real reading apps. This avoids building and maintaining three renderers.
|
||||
|
||||
Planned reading features:
|
||||
|
||||
- Custom fonts (including user-loaded), adjustable margins and line height
|
||||
- Themes including OLED true-black for battery
|
||||
- Paginated and scroll modes; gesture and volume-key page turns
|
||||
- Keep-screen-awake while reading
|
||||
- Highlights, notes, and bookmarks synced via existing APIs (including deleted-annotation restore)
|
||||
- Resume to exact position using EPUB CFI, consistent with universal sync
|
||||
|
||||
### Comics & manga UX
|
||||
|
||||
- RTL reading direction and double-page spreads with correct cover/single-page handling
|
||||
- Per-book reading-mode overrides (a manga library can default to RTL)
|
||||
- Zoom and pan; aggressive preloading of adjacent pages
|
||||
- Webtoon / continuous vertical mode: post-v1
|
||||
|
||||
### Reader settings parity with the web reader
|
||||
|
||||
The web reader (`web/src/reader/`) is the reference implementation for reading ergonomics — its font selection, reading themes, and highlight system are considered well-designed; only its desktop-oriented presentation is being replaced on mobile. The Android reader should reuse the same settings model (stored in the `reader_settings` table and synced via the settings endpoint) rather than inventing a parallel one:
|
||||
|
||||
- **Fonts**: the same roster of variable fonts, self-hosted under `/static/fonts/` — Literata (default), Crimson Pro, Source Serif 4, EB Garamond, Libertinus Serif, Noto Serif, Charis SIL, IBM Plex Serif (`FONT_MAP` in `web/src/reader/reader.ts`)
|
||||
- **Typography**: `font_size` (default 18), `line_height` (1.6), `margin_width`, `double_page_spread`
|
||||
- **Themes**: `chrome_theme` (default `tokyo-night`) for app chrome vs `reading_theme`/`reading_mode` for the page surface, plus the fx stack (`fx_brightness`, `fx_contrast`, `fx_invert`)
|
||||
- **Navigation**: `tap_zones_enabled` + `tap_zone_size`, `reading_direction`, `progress_mode`
|
||||
- **Highlights**: per-annotation color (default `#ffd54f`), matching the web palette
|
||||
|
||||
Settings chosen on one device should follow the user everywhere — mobile changes write back through the same sync.
|
||||
|
||||
---
|
||||
|
||||
## 🍎 iOS Posture
|
||||
|
||||
iOS is a real roadmap item but not near-term. The strategy is **not** to pre-pay for it with KMP or a cross-platform framework. Instead:
|
||||
|
||||
- The documented REST/WebSocket API is the sharing mechanism — a future iOS client is a *new client over the same contract*, never a rewrite of shared logic
|
||||
- A contributor-driven Swift/SwiftUI client is welcome; the server needs no changes to support it
|
||||
|
||||
---
|
||||
|
||||
## 📦 Distribution & Licensing
|
||||
|
||||
- **License**: AGPL-3.0, matching the Bookhoard server
|
||||
- **Channels**: GitHub Releases and F-Droid; Play Store optional later
|
||||
|
||||
---
|
||||
|
||||
## 🚧 Milestones
|
||||
|
||||
1. **Scaffold** — app shell, auth + QR device pairing, library browsing, book downloads
|
||||
2. **EPUB reading** — Readium integration, CFI progress sync, offline-first reading
|
||||
3. **Annotations** — highlights/notes/bookmarks sync with offline queue
|
||||
4. **Comics** — CBZ navigator with manga modes (RTL, spreads, zoom)
|
||||
5. **Polish** — OLED themes, gestures, background sync, storage management
|
||||
|
||||
---
|
||||
|
||||
## 🔭 Post-v1 Ideas
|
||||
|
||||
### QR pairing sign-in (Netflix-style)
|
||||
|
||||
"Add device" on the web (while logged in) displays a QR code; a fresh app install scans it and is **fully signed in** — no server URL, no password, nothing typed on the phone.
|
||||
|
||||
- **QR is a full login**: the claim endpoint returns JWT + refresh token (plus the device token for sync identity)
|
||||
- **Typed-code fallback** (GitHub/Netflix device-flow style: app displays a short code, user enters it on the web) for phones with broken cameras or no camera
|
||||
- **KOReader keeps its existing flow unchanged** — no typed-code pairing there; it is already as convenient as it can be
|
||||
- **Use the configured `BASE_URL`, never a detected LAN IP** — if the server is published at `https://public.domain`, pairing must work identically from outside the LAN
|
||||
- Requires small server additions: `pair`/`claim` endpoints backed by single-use pairing sessions with a short TTL (in-memory like `pendingRegistrations`)
|
||||
|
||||
### Other ideas
|
||||
|
||||
- Webtoon / continuous vertical reading mode
|
||||
- Home-screen widgets and app shortcuts ("continue reading")
|
||||
- Text-to-speech
|
||||
- OPDS feed consumption from other servers
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **[API Reference](api/api-reference.md)** - Complete REST API
|
||||
- **[WebSocket API](websocket-api.md)** - Real-time sync events
|
||||
- **[Sync Guide](../user/sync-guide.md)** - How universal sync works
|
||||
- **[Devices API](api/devices/)** - Device registration and approval
|
||||
@@ -89,7 +89,7 @@ See [Media Item Operations](media-items/)
|
||||
- GET /api/media-items/:id - Get media item details
|
||||
- POST /api/media-items/bulk-delete - Bulk delete media items
|
||||
- POST /api/media-items/bulk-update - Bulk update media items (tags/contributors with normalization)
|
||||
- GET /api/media-items/:uuid/download - Download media item file
|
||||
- GET /uploads/library-{library_id}/{file_path} - Download book file / cover (JWT; see [Download Media Item](media-items/download_media_item.md))
|
||||
- POST /api/media-items/:id/rating - Create rating
|
||||
- GET /api/media-items/:id/rating - Get rating
|
||||
- PUT /api/media-items/:id/rating - Update rating
|
||||
|
||||
@@ -26,7 +26,7 @@ Authenticate with email and password.
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"refresh_token": "d4f5g6h7...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 604800,
|
||||
@@ -41,6 +41,8 @@ Authenticate with email and password.
|
||||
}
|
||||
```
|
||||
|
||||
Note: the access token field is `access_token` (not `token`). Nullable profile fields (`first_name`, `last_name`) may be empty strings.
|
||||
|
||||
**Set-Cookie Header**:
|
||||
|
||||
```
|
||||
|
||||
@@ -24,7 +24,9 @@ Check device registration status or get device details.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "pending|approved|expired",
|
||||
"status": "pending|approved",
|
||||
"message": "awaiting user approval",
|
||||
"expires_in": 123,
|
||||
"auth_token": "device-bearer-token...",
|
||||
"device_id": "uuid",
|
||||
"sync_endpoints": {
|
||||
@@ -35,24 +37,16 @@ Check device registration status or get device details.
|
||||
}
|
||||
```
|
||||
|
||||
## Response (200 OK) - Device Details
|
||||
`status` is `pending` or `approved`. While pending, the response includes `message` and `expires_in` (seconds remaining). Once approved, the response includes `auth_token`, `device_id`, and `sync_endpoints`; `auth_token` fields are empty when pending.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"device_name": "My Kobo Clara",
|
||||
"device_type": "kobo",
|
||||
"last_sync": "2026-01-31T10:00:00Z",
|
||||
"last_seen": "2026-01-31T10:05:00Z",
|
||||
"sync_enabled": true,
|
||||
"auto_sync": true,
|
||||
"sync_frequency_minutes": 5
|
||||
}
|
||||
```
|
||||
**The approved response is single-use**: the registration is deleted from the pending map once returned, so store the `auth_token` immediately. A repeat status check for the same `registration_id` returns 404.
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | --------------------------------------------- |
|
||||
| 401 | Invalid or expired token (for device details) |
|
||||
| 404 | Device or registration not found |
|
||||
| Code | Description |
|
||||
| ---- | -------------------------------------------------- |
|
||||
| 400 | Invalid or missing `registration_id` |
|
||||
| 404 | Registration not found (unknown or already issued) |
|
||||
| 410 | Registration expired (`{"error": "registration expired"}`) |
|
||||
|
||||
Note: expiration is signaled by HTTP 410 Gone, not a `"status": "expired"` value. Pending registrations are held in server memory, so a server restart also invalidates them (subsequent checks return 404).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Retrieve all libraries visible to the current user.
|
||||
|
||||
**Endpoint**: `GET /api/libraries/visible`
|
||||
**Endpoint**: `GET /api/libraries/visibility`
|
||||
**Auth**: Required
|
||||
|
||||
## Request Headers
|
||||
@@ -14,26 +14,33 @@ Retrieve all libraries visible to the current user.
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
GET /api/libraries/visible
|
||||
GET /api/libraries/visibility
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
A top-level JSON **array** of library rows:
|
||||
|
||||
```json
|
||||
{
|
||||
"libraries": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "My Ebooks",
|
||||
"description": "Ebook collection",
|
||||
"type_name": "ebooks",
|
||||
"is_visible": true
|
||||
}
|
||||
]
|
||||
}
|
||||
[
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "My Ebooks",
|
||||
"description": "Ebook collection",
|
||||
"library_type_id": "uuid",
|
||||
"created_by_admin_id": "uuid",
|
||||
"created_at": "2026-01-31T10:00:00Z",
|
||||
"updated_at": "2026-01-31T10:00:00Z",
|
||||
"type_name": "ebooks",
|
||||
"type_description": "Ebook libraries",
|
||||
"is_visible": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Nullable columns (`description`, `type_description`) serialize as `null` when unset. Timestamps are RFC 3339.
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
|
||||
Download a media item file (EPUB, PDF, etc.) from the Bookhoard server.
|
||||
|
||||
**Endpoint**: `GET /api/media-items/:uuid/download`
|
||||
**Auth**: None (public endpoint for Kobo devices)
|
||||
**Content-Type**: Binary file download
|
||||
Book files are served by the authenticated file route, the same one the web reader uses. Build the URL from the media item's `library_id` and relative `file_path` (both returned by the media item list/get endpoints):
|
||||
|
||||
**Endpoint**: `GET /uploads/library-{library_id}/{file_path}`
|
||||
**Auth**: Required (JWT - Bearer header or session cookie)
|
||||
|
||||
The `file_path` segments are URL-escaped individually; slashes are preserved. `cover_image_path` uses the same route.
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------ | -------- | --------------- |
|
||||
| uuid | string | Yes | Media item UUID |
|
||||
| Parameter | Type | Required | Description |
|
||||
| ----------- | ------ | -------- | ------------------------------------ |
|
||||
| library_id | string | Yes | Library UUID (the item's library) |
|
||||
| file_path | string | Yes | The item's relative `file_path` |
|
||||
|
||||
## Response
|
||||
|
||||
@@ -18,25 +22,27 @@ Download a media item file (EPUB, PDF, etc.) from the Bookhoard server.
|
||||
|
||||
**Response Headers**:
|
||||
|
||||
- `Content-Type`: `application/epub+zip`, `application/pdf`, or appropriate MIME type
|
||||
- `Content-Disposition`: `attachment; filename="filename.epub"`
|
||||
- `Content-Type`: MIME type by file extension (`application/epub+zip`, `application/pdf`, …; `application/octet-stream` fallback)
|
||||
- `Cache-Control`: `public, max-age=86400`
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | --------------------------------- |
|
||||
| 404 | Media item not found |
|
||||
| 500 | Server error during file download |
|
||||
| Code | Description |
|
||||
| ---- | --------------------------- |
|
||||
| 400 | Invalid library ID or path |
|
||||
| 401 | Missing/invalid token |
|
||||
| 404 | File not found on disk |
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
curl -O http://localhost:8765/api/media-items/550e8400-e29b-41d4-a716-446655440000/download
|
||||
curl -O -H "Authorization: Bearer $TOKEN" \
|
||||
"http://localhost:8765/uploads/library/550e8400-.../books/1984.epub"
|
||||
```
|
||||
|
||||
(URL shape: `/uploads/library-{uuid}/{escaped-relative-path}`.)
|
||||
|
||||
## Notes
|
||||
|
||||
- **Public endpoint**: No authentication required for Kobo device downloads
|
||||
- **File format**: Returns the original file format (EPUB, PDF, etc.)
|
||||
- **Kobo integration**: Designed for direct downloads from Kobo e-readers
|
||||
- **Cover images**: Use `/api/media-items/:uuid/cover` for cover images
|
||||
- **Do not rely on `GET /api/media-items/:id/download`** — it appears in older docs but is **not registered**; `MediaHandler.DownloadBook` exists as dead code. Use the file route above.
|
||||
- OPDS-capable devices may alternatively use the device-authenticated `GET /opds/devices/{deviceId}/download/{bookId}`, which supports on-the-fly format conversion (epub, kepub, pdf, cbz).
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
# List Media Items
|
||||
|
||||
Retrieve a paginated list of media items from a library.
|
||||
Retrieve a paginated list of media items, scoped to a library or across all libraries.
|
||||
|
||||
**Endpoint**: `GET /api/media-items`
|
||||
**Auth**: Required
|
||||
|
||||
## Query Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| ---------- | ------- | -------- | ----------------------------------------------- |
|
||||
| library_id | string | Yes | Library UUID |
|
||||
| limit | integer | No | Number of items to return (max 100, default 20) |
|
||||
| offset | integer | No | Number of items to skip |
|
||||
| Parameter | Type | Required | Description |
|
||||
| ---------- | ------ | -------- | ------------------------------------------------------ |
|
||||
| library_id | string | No | Library UUID. If omitted, items from all libraries are returned |
|
||||
| limit | int | No | Items to return (default 50, max 1000) |
|
||||
| offset | int | No | Items to skip (must be >= 0) |
|
||||
| sort | string | No | Sort expression, default `created_at DESC` |
|
||||
|
||||
### Allowed sort expressions
|
||||
|
||||
`created_at`, `title`, `author`, `series`, `date_published`, `copyright_year`, `page_count`, `genre` — each with ` ASC` or ` DESC` (e.g. `title ASC`). Any other value silently falls back to `created_at DESC`.
|
||||
|
||||
## Request Headers
|
||||
|
||||
@@ -22,46 +27,121 @@ Retrieve a paginated list of media items from a library.
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
GET /api/media-items?library_id=uuid&limit=20&offset=0
|
||||
GET /api/media-items?library_id=uuid&limit=20&offset=0&sort=title%20ASC
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
The response body is `{"data": [...]}` in both modes. The item shape differs by mode.
|
||||
|
||||
**No total is returned** — page until fewer items than `limit` come back.
|
||||
|
||||
### With `library_id` — full database rows
|
||||
|
||||
Nullable columns serialize as `null`.
|
||||
|
||||
```json
|
||||
{
|
||||
"media_items": [
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"library_id": "uuid",
|
||||
"title": "Book Title",
|
||||
"author": "Author Name",
|
||||
"isbn": "978-...",
|
||||
"description": "Book description",
|
||||
"file_path": "/path/to/book.epub",
|
||||
"file_path": "relative/path/book.epub",
|
||||
"file_size": 1024000,
|
||||
"mime_type": "application/epub+zip",
|
||||
"cover_image_path": "/path/to/cover.jpg",
|
||||
"cover_image_path": "relative/path/cover.jpg",
|
||||
"series": "Series Name",
|
||||
"series_number": 1,
|
||||
"tags": ["sci-fi", "space opera"],
|
||||
"tags_search": ["sci fi", "space opera"],
|
||||
"contributors": ["Author Name", "ACME CORP."],
|
||||
"contributors_search": ["author name", "acme corp"],
|
||||
"tags": ["sci-fi"],
|
||||
"asin": null,
|
||||
"date_published": "2023-06-01",
|
||||
"publisher": null,
|
||||
"contributors": ["Author Name"],
|
||||
"language": "en",
|
||||
"edition": null,
|
||||
"page_count": 350,
|
||||
"genre": "Science Fiction",
|
||||
"copyright_year": 2023,
|
||||
"created_at": "2026-01-31T10:00:00Z"
|
||||
"goodreads_id": null,
|
||||
"openlibrary_id": null,
|
||||
"google_books_id": null,
|
||||
"added_by_admin_id": "uuid",
|
||||
"created_at": "2026-01-31T10:00:00Z",
|
||||
"imported_at": "2026-01-31T10:00:00Z",
|
||||
"updated_at": "2026-01-31T10:00:00Z",
|
||||
"format_group": "epub",
|
||||
"format_mimetype": "application/epub+zip",
|
||||
"is_reflowable": true,
|
||||
"has_fixed_layout": false,
|
||||
"total_characters": 480000,
|
||||
"chapter_count": 24
|
||||
}
|
||||
],
|
||||
"total": 100
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Note: in this mode `file_path` and `cover_image_path` are the raw relative storage paths, not URLs.
|
||||
|
||||
### Without `library_id` — curated items with resolved URLs
|
||||
|
||||
Across all libraries; file and cover paths are resolved to fetchable URL paths (`/uploads/...` or library-scoped paths):
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"library_id": "uuid",
|
||||
"title": "Book Title",
|
||||
"author": "Author Name",
|
||||
"isbn": "978-...",
|
||||
"description": "Book description",
|
||||
"file_path": "/api/libraries/<uuid>/files/...",
|
||||
"file_size": 1024000,
|
||||
"mime_type": "application/epub+zip",
|
||||
"cover_image_path": "/api/libraries/<uuid>/files/.../cover.jpg",
|
||||
"series": "Series Name",
|
||||
"series_number": 1,
|
||||
"tags": ["sci-fi"],
|
||||
"asin": null,
|
||||
"date_published": "2023-06-01",
|
||||
"publisher": null,
|
||||
"contributors": ["Author Name"],
|
||||
"language": "en",
|
||||
"edition": null,
|
||||
"page_count": 350,
|
||||
"genre": "Science Fiction",
|
||||
"created_at": "2026-01-31T10:00:00Z",
|
||||
"updated_at": "2026-01-31T10:00:00Z",
|
||||
"format_group": "epub",
|
||||
"manga_type": null,
|
||||
"reading_direction": null,
|
||||
"series_count": null,
|
||||
"volume": null,
|
||||
"imprint": null,
|
||||
"age_rating": null,
|
||||
"web_url": null,
|
||||
"metadata_notes": null,
|
||||
"community_rating": null,
|
||||
"story_arc": null,
|
||||
"is_black_and_white": false,
|
||||
"alternate_info": null,
|
||||
"scan_information": null,
|
||||
"summary": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------------------------------------- |
|
||||
| 400 | Invalid query parameters |
|
||||
| 401 | Invalid or expired token |
|
||||
| 403 | User does not have access to this library |
|
||||
| Code | Description |
|
||||
| ---- | ------------------------------------------ |
|
||||
| 400 | Invalid `library_id`, `offset` < 0 |
|
||||
| 401 | Invalid or expired token |
|
||||
| 500 | Query failure (returned as `{"error": …}`) |
|
||||
|
||||
+72
-66
@@ -11,7 +11,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -191,55 +190,6 @@ func (mh *MediaHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||
mh.annotationSvc = svc
|
||||
}
|
||||
|
||||
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
|
||||
bookUUID, err := uuid.Parse(c.Param("uuid"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book UUID"})
|
||||
}
|
||||
|
||||
pgBookUUID := pgtype.UUID{Bytes: bookUUID, Valid: true}
|
||||
|
||||
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgBookUUID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
||||
}
|
||||
|
||||
// Resolve relative path to absolute filesystem path
|
||||
fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found on disk"})
|
||||
}
|
||||
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found on disk"})
|
||||
}
|
||||
|
||||
file, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to open book file"})
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
mimeType := mediaItem.MimeType.String
|
||||
if !mediaItem.MimeType.Valid || mimeType == "" {
|
||||
mimeType = mime.TypeByExtension(filepath.Ext(mediaItem.FilePath))
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Content-Type", mimeType)
|
||||
c.Response().Header().Set("Content-Disposition", "attachment; filename=\""+filepath.Base(mediaItem.FilePath)+"\"")
|
||||
|
||||
if mediaItem.FileSize.Valid && mediaItem.FileSize.Int64 > 0 {
|
||||
c.Response().Header().Set("Content-Length", strconv.FormatInt(mediaItem.FileSize.Int64, 10))
|
||||
}
|
||||
|
||||
_, err = io.Copy(c.Response(), file)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to stream file"})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteSearch performs search and returns results with count
|
||||
// Public wrapper for shared search logic used by both JSON and HTML endpoints
|
||||
func (h *MediaHandler) ExecuteSearch(ctx context.Context, params services.SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
|
||||
@@ -2073,30 +2023,86 @@ func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UU
|
||||
return mh.libraryService.ResolveMediaPath(ctx, libraryID, relativePath)
|
||||
}
|
||||
|
||||
// ServeFile serves files (covers or books) via /uploads/library-{id}/path
|
||||
// Requires JWT authentication
|
||||
// ServeFile serves stored library files (covers and books).
|
||||
//
|
||||
// Two URL forms funnel into this handler:
|
||||
//
|
||||
// /uploads/library-{libraryID}/{relativePath} (covers, reader files)
|
||||
// /api/media-items/{mediaItemID}/download (explicit book download)
|
||||
//
|
||||
// Both require JWT authentication and that the authenticated user can see
|
||||
// the library owning the file - library visibility is the permission gate.
|
||||
func (mh *MediaHandler) ServeFile(c *echo.Context) error {
|
||||
// URL format: /uploads/library-{libraryID}/{relativePath}
|
||||
// Get library ID directly from route parameter
|
||||
libraryIDStr := c.Param("id")
|
||||
libraryUUID, err := uuid.Parse(libraryIDStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library ID"})
|
||||
}
|
||||
var libraryUUID pgtype.UUID
|
||||
var relativePath string
|
||||
|
||||
// Get remaining path from URL
|
||||
rawPath := c.Param("*")
|
||||
relativePath, err := url.QueryUnescape(rawPath)
|
||||
if err != nil {
|
||||
relativePath = rawPath
|
||||
if rawPath != "" {
|
||||
// Path form: /uploads/library-{libraryID}/{relativePath}
|
||||
libraryIDStr := c.Param("id")
|
||||
parsed, err := uuid.Parse(libraryIDStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library ID"})
|
||||
}
|
||||
libraryUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
|
||||
relativePath, err = url.QueryUnescape(rawPath)
|
||||
if err != nil {
|
||||
relativePath = rawPath
|
||||
}
|
||||
|
||||
if relativePath == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid path"})
|
||||
}
|
||||
} else {
|
||||
// Item form: /api/media-items/{mediaItemID}/download
|
||||
itemUUID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item ID"})
|
||||
}
|
||||
|
||||
mediaItem, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: itemUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
||||
}
|
||||
|
||||
if !mediaItem.LibraryID.Valid || mediaItem.FilePath == "" {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found"})
|
||||
}
|
||||
|
||||
libraryUUID = mediaItem.LibraryID
|
||||
relativePath = mediaItem.FilePath
|
||||
|
||||
// Explicit download endpoint: suggest saving instead of inline display.
|
||||
filename := strings.Map(func(r rune) rune {
|
||||
if r == '"' || r == '\\' || r == '/' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, filepath.Base(relativePath))
|
||||
c.Response().Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
||||
}
|
||||
|
||||
if relativePath == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid path"})
|
||||
// Library visibility gate: the library is what grants permission to
|
||||
// see and download media.
|
||||
user := c.Get("user").(database.Users)
|
||||
visibleLibraries, err := mh.libraryService.GetUserVisibleLibraries(c.Request().Context(), user.ID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"})
|
||||
}
|
||||
libraryVisible := false
|
||||
for _, lib := range visibleLibraries {
|
||||
if lib.ID.Valid && lib.ID.Bytes == libraryUUID.Bytes {
|
||||
libraryVisible = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !libraryVisible {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||
}
|
||||
|
||||
// Resolve using service
|
||||
fullPath, err := mh.getFullFilePath(c.Request().Context(), pgtype.UUID{Bytes: libraryUUID, Valid: true}, relativePath)
|
||||
fullPath, err := mh.getFullFilePath(c.Request().Context(), libraryUUID, relativePath)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ func registerMediaRoutes(cfg *Config) {
|
||||
// Media item routes (all authenticated users)
|
||||
protected.GET("/media-items", cfg.MediaHandler.ListMediaItems)
|
||||
protected.GET("/media-items/:id", cfg.MediaHandler.GetMediaItem)
|
||||
// Book download endpoint - same ServeFile flow as /uploads/library-:id/*
|
||||
// (JWT + library-visibility gated), addressed by media item ID.
|
||||
protected.GET("/media-items/:id/download", cfg.MediaHandler.ServeFile)
|
||||
|
||||
// Media rating routes (all authenticated users)
|
||||
protected.POST("/media-items/:id/rating", cfg.MediaHandler.CreateMediaRating)
|
||||
|
||||
Reference in New Issue
Block a user