Compare commits
2
Commits
7ddcdd0756
...
311049379d
| 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)
|
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
|
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
|
- **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
|
||||||
- JWT access/refresh handled by the existing auth endpoints
|
- 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
|
## 🔭 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
|
- Webtoon / continuous vertical reading mode
|
||||||
- Home-screen widgets and app shortcuts ("continue reading")
|
- Home-screen widgets and app shortcuts ("continue reading")
|
||||||
- Text-to-speech
|
- Text-to-speech
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Authenticate with email and password.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
"refresh_token": "d4f5g6h7...",
|
"refresh_token": "d4f5g6h7...",
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"expires_in": 604800,
|
"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**:
|
**Set-Cookie Header**:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ Check device registration status or get device details.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "pending|approved|expired",
|
"status": "pending|approved",
|
||||||
|
"message": "awaiting user approval",
|
||||||
|
"expires_in": 123,
|
||||||
"auth_token": "device-bearer-token...",
|
"auth_token": "device-bearer-token...",
|
||||||
"device_id": "uuid",
|
"device_id": "uuid",
|
||||||
"sync_endpoints": {
|
"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
|
**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.
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
| ---- | --------------------------------------------- |
|
| ---- | -------------------------------------------------- |
|
||||||
| 401 | Invalid or expired token (for device details) |
|
| 400 | Invalid or missing `registration_id` |
|
||||||
| 404 | Device or registration not found |
|
| 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.
|
Retrieve all libraries visible to the current user.
|
||||||
|
|
||||||
**Endpoint**: `GET /api/libraries/visible`
|
**Endpoint**: `GET /api/libraries/visibility`
|
||||||
**Auth**: Required
|
**Auth**: Required
|
||||||
|
|
||||||
## Request Headers
|
## Request Headers
|
||||||
@@ -14,26 +14,33 @@ Retrieve all libraries visible to the current user.
|
|||||||
### Example Request
|
### Example Request
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/libraries/visible
|
GET /api/libraries/visibility
|
||||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Response (200 OK)
|
## Response (200 OK)
|
||||||
|
|
||||||
|
A top-level JSON **array** of library rows:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
[
|
||||||
"libraries": [
|
{
|
||||||
{
|
"id": "uuid",
|
||||||
"id": "uuid",
|
"name": "My Ebooks",
|
||||||
"name": "My Ebooks",
|
"description": "Ebook collection",
|
||||||
"description": "Ebook collection",
|
"library_type_id": "uuid",
|
||||||
"type_name": "ebooks",
|
"created_by_admin_id": "uuid",
|
||||||
"is_visible": true
|
"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
|
## Error Responses
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
# List Media Items
|
# 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`
|
**Endpoint**: `GET /api/media-items`
|
||||||
**Auth**: Required
|
**Auth**: Required
|
||||||
|
|
||||||
## Query Parameters
|
## Query Parameters
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
| Parameter | Type | Required | Description |
|
||||||
| ---------- | ------- | -------- | ----------------------------------------------- |
|
| ---------- | ------ | -------- | ------------------------------------------------------ |
|
||||||
| library_id | string | Yes | Library UUID |
|
| library_id | string | No | Library UUID. If omitted, items from all libraries are returned |
|
||||||
| limit | integer | No | Number of items to return (max 100, default 20) |
|
| limit | int | No | Items to return (default 50, max 1000) |
|
||||||
| offset | integer | No | Number of items to skip |
|
| 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
|
## Request Headers
|
||||||
|
|
||||||
@@ -22,46 +27,121 @@ Retrieve a paginated list of media items from a library.
|
|||||||
### Example Request
|
### Example Request
|
||||||
|
|
||||||
```http
|
```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...
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Response (200 OK)
|
## 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
|
```json
|
||||||
{
|
{
|
||||||
"media_items": [
|
"data": [
|
||||||
{
|
{
|
||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
"library_id": "uuid",
|
"library_id": "uuid",
|
||||||
"title": "Book Title",
|
"title": "Book Title",
|
||||||
"author": "Author Name",
|
"author": "Author Name",
|
||||||
|
"isbn": "978-...",
|
||||||
"description": "Book description",
|
"description": "Book description",
|
||||||
"file_path": "/path/to/book.epub",
|
"file_path": "relative/path/book.epub",
|
||||||
"file_size": 1024000,
|
"file_size": 1024000,
|
||||||
"mime_type": "application/epub+zip",
|
"mime_type": "application/epub+zip",
|
||||||
"cover_image_path": "/path/to/cover.jpg",
|
"cover_image_path": "relative/path/cover.jpg",
|
||||||
"series": "Series Name",
|
"series": "Series Name",
|
||||||
"series_number": 1,
|
"series_number": 1,
|
||||||
"tags": ["sci-fi", "space opera"],
|
"tags": ["sci-fi"],
|
||||||
"tags_search": ["sci fi", "space opera"],
|
"asin": null,
|
||||||
"contributors": ["Author Name", "ACME CORP."],
|
"date_published": "2023-06-01",
|
||||||
"contributors_search": ["author name", "acme corp"],
|
"publisher": null,
|
||||||
|
"contributors": ["Author Name"],
|
||||||
"language": "en",
|
"language": "en",
|
||||||
|
"edition": null,
|
||||||
"page_count": 350,
|
"page_count": 350,
|
||||||
"genre": "Science Fiction",
|
"genre": "Science Fiction",
|
||||||
"copyright_year": 2023,
|
"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
|
## Error Responses
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
| ---- | ----------------------------------------- |
|
| ---- | ------------------------------------------ |
|
||||||
| 400 | Invalid query parameters |
|
| 400 | Invalid `library_id`, `offset` < 0 |
|
||||||
| 401 | Invalid or expired token |
|
| 401 | Invalid or expired token |
|
||||||
| 403 | User does not have access to this library |
|
| 500 | Query failure (returned as `{"error": …}`) |
|
||||||
|
|||||||
Reference in New Issue
Block a user