Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
311049379d | ||
|
|
f65db5ab4f |
@@ -68,10 +68,11 @@ Keeping `:core:domain` free of Android dependencies preserves optionality: a fut
|
||||
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
|
||||
|
||||
### Device registration & auth
|
||||
### Authentication & device identity
|
||||
|
||||
- The app registers as a Bookhoard **device** using the existing QR-approval flow (`POST /api/devices/register` + web-based approval) — no passwords stored on the device
|
||||
- JWT access/refresh handled by the existing auth endpoints
|
||||
- **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)
|
||||
|
||||
---
|
||||
|
||||
@@ -125,6 +126,18 @@ iOS is a real roadmap item but not near-term. The strategy is **not** to pre-pay
|
||||
|
||||
## 🔭 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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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": …}`) |
|
||||
|
||||
Reference in New Issue
Block a user