docs: update comprehensive API documentation and project guides
This commit updates all documentation files throughout the project: - Updated IMPLEMENTATION_PLAN.md with new implementation details - Updated PROJECT_GUIDELINES.md with coding standards and practices - Updated README.md with current project information - Updated SCREENSHOT_AUTOMATION.md with new automation details - Added TEST_DATA.md with test fixtures data - Updated cover_image_serving_plan.md with static URL patterns Documentation API updates: - Updated API reference documentation for all endpoints including: - Authentication (login, logout, register, refresh_token) - Book matching (auto_link, bulk_link, link_book, search) - Collections (CRUD operations, shelf mappings, auto-assign rules) - Conflicts (bulk operations, resolve/dismiss) - Devices (registration, approval, shelf management) - Highlights (create, update, delete, get) - Kobo sync (bookmark, markup, initialization, sync) - KOReader sync (library, metadata, bookmarks, progress) - Libraries (CRUD, folders, media items, stats) - Media items (bulk operations, CRUD) - Notes (CRUD operations) - OPDS (acquisition, feeds, publication) - Progress (reading progress tracking) - Queue (device queue management) - Ratings (star ratings) - Scanner (watch mode, scan operations) - Sync protocols (Kobo, KOReader) - Users (profile, password, admin operations) - WebSocket protocols - Updated user guides (admin, dashboard, settings, sync) - Updated device setup guides (Kobo, KOReader) - Updated developer guides (testing, contributing, operations) - Updated scripts/README.md
This commit is contained in:
+132
-93
@@ -3,6 +3,7 @@
|
||||
## Overview
|
||||
|
||||
Fix file and cover image serving to support:
|
||||
|
||||
1. Multiple library folders in docker compose (flexible mount points)
|
||||
2. Keep files with books (no hardcoded paths)
|
||||
3. Store relative paths in database (for both files AND covers)
|
||||
@@ -12,12 +13,14 @@ Fix file and cover image serving to support:
|
||||
## Architecture
|
||||
|
||||
### Current Behavior
|
||||
|
||||
- File path stored as absolute: `/app/uploads/Jane Austen/Pride and Prejudice/book.epub`
|
||||
- Cover path stored as absolute: `/app/uploads/Jane Austen/Pride and Prejudice/cover.jpg`
|
||||
- Frontend uses path directly - doesn't work (browser can't access container paths)
|
||||
- No route serves `/app/uploads/*`
|
||||
|
||||
### Target Behavior
|
||||
|
||||
- File path stored as relative: `Jane Austen/Pride and Prejudice/book.epub`
|
||||
- Cover path stored as relative: `Jane Austen/Pride and Prejudice/cover.jpg`
|
||||
- Handler resolves relative path using library folder base path
|
||||
@@ -26,16 +29,21 @@ Fix file and cover image serving to support:
|
||||
- Works with mobile apps, Kobo, KOReader devices via same endpoints
|
||||
|
||||
### URL Format
|
||||
|
||||
To handle same relative paths in different libraries, use:
|
||||
|
||||
```
|
||||
/uploads/library-{library_id}/relative/path
|
||||
```
|
||||
|
||||
- Requires JWT authentication (like API endpoints)
|
||||
- Works for both covers and book files
|
||||
- Single handler handles all file serving
|
||||
|
||||
### Universal Path Resolution
|
||||
|
||||
All handlers use the same `LibraryService.ResolveMediaPath()` function:
|
||||
|
||||
- MediaHandler (downloads)
|
||||
- OPDSHandler (device cover images)
|
||||
- Future handlers
|
||||
@@ -53,16 +61,19 @@ This ensures one source of truth for path resolution.
|
||||
**Location**: In `internal/services/media_scanner.go` - wherever `FilePath` is set in the database insert
|
||||
|
||||
**Current code** (line 579):
|
||||
|
||||
```go
|
||||
FilePath: path, // path is absolute like /app/uploads/Author/Book/file.epub
|
||||
```
|
||||
|
||||
**New code**:
|
||||
|
||||
```go
|
||||
FilePath: s.getRelativePath(path),
|
||||
```
|
||||
|
||||
**Also update** line 617 for format file paths:
|
||||
|
||||
```go
|
||||
FilePath: pgtype.Text{String: s.getRelativePath(format.FilePath), Valid: true},
|
||||
```
|
||||
@@ -74,6 +85,7 @@ FilePath: pgtype.Text{String: s.getRelativePath(format.FilePath), Valid: tr
|
||||
**Location**: In `internal/services/media_scanner.go` - wherever `metadata.CoverPath` is set
|
||||
|
||||
**Current code** (example at line 517):
|
||||
|
||||
```go
|
||||
if len(coverImage) > 0 && metadata.CoverPath == "" {
|
||||
coverPath := path + ".cover.jpg"
|
||||
@@ -84,6 +96,7 @@ if len(coverImage) > 0 && metadata.CoverPath == "" {
|
||||
```
|
||||
|
||||
**New code**:
|
||||
|
||||
```go
|
||||
if len(coverImage) > 0 && metadata.CoverPath == "" {
|
||||
coverPath := path + ".cover.jpg"
|
||||
@@ -95,6 +108,7 @@ if len(coverImage) > 0 && metadata.CoverPath == "" {
|
||||
```
|
||||
|
||||
**All locations where metadata.CoverPath is set**:
|
||||
|
||||
- Line 517 (main cover)
|
||||
- Line 645 (sidecar cover)
|
||||
- Line 651 (sidecar cover alternative)
|
||||
@@ -187,12 +201,12 @@ func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UU
|
||||
if relativePath == "" {
|
||||
return "", fmt.Errorf("no file path")
|
||||
}
|
||||
|
||||
|
||||
// Check if already absolute (backward compatibility)
|
||||
if filepath.IsAbs(relativePath) {
|
||||
return relativePath, nil
|
||||
}
|
||||
|
||||
|
||||
// Use service for resolution (one source of truth)
|
||||
return mh.libraryService.ResolveMediaPath(ctx, libraryID, relativePath)
|
||||
}
|
||||
@@ -209,6 +223,7 @@ Note: The handler already has `libraryService` injected, so this just calls thro
|
||||
#### Modify DownloadBook function
|
||||
|
||||
**Current code** (line 103-144):
|
||||
|
||||
```go
|
||||
func (h *MediaHandler) DownloadBook(c echo.Context) error {
|
||||
// ...
|
||||
@@ -227,6 +242,7 @@ func (h *MediaHandler) DownloadBook(c echo.Context) error {
|
||||
```
|
||||
|
||||
**New code**:
|
||||
|
||||
```go
|
||||
func (h *MediaHandler) DownloadBook(c echo.Context) error {
|
||||
// ...
|
||||
@@ -264,32 +280,32 @@ Create a single handler that serves both covers and book files:
|
||||
func (mh *MediaHandler) ServeFile(c echo.Context) error {
|
||||
// URL format: /uploads/library-{libraryID}/{relativePath}
|
||||
path := c.Param("*") // Gets everything after /uploads/library-{id}/
|
||||
|
||||
|
||||
// Extract library ID from path
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
if len(parts) < 2 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid path"})
|
||||
}
|
||||
|
||||
|
||||
libraryIDStr := strings.TrimPrefix(parts[0], "library-")
|
||||
libraryUUID, err := uuid.Parse(libraryIDStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library ID"})
|
||||
}
|
||||
|
||||
|
||||
relativePath := parts[1]
|
||||
|
||||
|
||||
// Resolve using service
|
||||
fullPath, err := mh.getFullFilePath(c.Request().Context(), pgtype.UUID{Bytes: libraryUUID, Valid: true}, relativePath)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
||||
}
|
||||
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
||||
}
|
||||
|
||||
|
||||
// Determine content type
|
||||
ext := strings.ToLower(filepath.Ext(fullPath))
|
||||
contentType := "application/octet-stream"
|
||||
@@ -304,7 +320,7 @@ func (mh *MediaHandler) ServeFile(c echo.Context) error {
|
||||
} else if ext == ".pdf" {
|
||||
contentType = "application/pdf"
|
||||
}
|
||||
|
||||
|
||||
c.Response().Header().Set("Content-Type", contentType)
|
||||
c.Response().Header().Set("Cache-Control", "public, max-age=86400")
|
||||
return c.File(fullPath)
|
||||
@@ -332,16 +348,17 @@ e.GET("/uploads/library-:id/*", createJWTMiddleware(cfg), cfg.MediaHandler.Serve
|
||||
#### Modify GetCoverImage function
|
||||
|
||||
**Current code** (around line 477-549):
|
||||
|
||||
```go
|
||||
func (h *OPDSHandler) GetCoverImage(c echo.Context) error {
|
||||
// ...
|
||||
coverPath := mediaItem.CoverImagePath.String
|
||||
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(coverPath); os.IsNotExist(err) {
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
// Open file
|
||||
file, err := os.Open(coverPath)
|
||||
// ...
|
||||
@@ -349,22 +366,23 @@ func (h *OPDSHandler) GetCoverImage(c echo.Context) error {
|
||||
```
|
||||
|
||||
**New code**:
|
||||
|
||||
```go
|
||||
func (h *OPDSHandler) GetCoverImage(c echo.Context) error {
|
||||
// ...
|
||||
coverPath := mediaItem.CoverImagePath.String
|
||||
|
||||
|
||||
// Resolve relative path using library service
|
||||
fullPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, coverPath)
|
||||
if err != nil {
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
// Open file
|
||||
file, err := os.Open(fullPath)
|
||||
// ...
|
||||
@@ -417,7 +435,7 @@ func (mh *MediaHandler) ResolveCoverURL(libraryID pgtype.UUID, coverPath pgtype.
|
||||
if !coverPath.Valid || coverPath.String == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
return mh.resolveMediaURL(libraryID, coverPath.String)
|
||||
}
|
||||
|
||||
@@ -426,7 +444,7 @@ func (mh *MediaHandler) ResolveFileURL(libraryID pgtype.UUID, filePath pgtype.Te
|
||||
if !filePath.Valid || filePath.String == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
return mh.resolveMediaURL(libraryID, filePath.String)
|
||||
}
|
||||
|
||||
@@ -436,13 +454,13 @@ func (mh *MediaHandler) resolveMediaURL(libraryID pgtype.UUID, relativePath stri
|
||||
if strings.HasPrefix(relativePath, "/uploads/") {
|
||||
return relativePath
|
||||
}
|
||||
|
||||
|
||||
// Already absolute path? Convert to URL format (backward compatibility)
|
||||
// Note: This loses library ID info, but existing data won't have it
|
||||
if filepath.IsAbs(relativePath) {
|
||||
return relativePath
|
||||
}
|
||||
|
||||
|
||||
// Resolve relative path to URL format
|
||||
libraryIDStr := libraryID.Bytes.String()
|
||||
return fmt.Sprintf("/uploads/library-%s/%s", libraryIDStr, relativePath)
|
||||
@@ -494,6 +512,7 @@ cfg.CollectionHandler, err = handlers.NewCollectionHandler(cfg.Queries, cfg.Libr
|
||||
**File**: `internal/handlers/collections.go`
|
||||
|
||||
**Current code** (lines 193-201 in GetCollection function):
|
||||
|
||||
```go
|
||||
bookList := make([]BookInfo, 0, len(books))
|
||||
for _, book := range books {
|
||||
@@ -507,6 +526,7 @@ for _, book := range books {
|
||||
```
|
||||
|
||||
**New code**:
|
||||
|
||||
```go
|
||||
bookList := make([]BookInfo, 0, len(books))
|
||||
for _, book := range books {
|
||||
@@ -527,17 +547,17 @@ func (h *CollectionHandler) resolveCoverURL(libraryID pgtype.UUID, coverPath pgt
|
||||
if !coverPath.Valid || coverPath.String == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
// Already a full URL? Return as-is
|
||||
if strings.HasPrefix(coverPath.String, "/uploads/") {
|
||||
return coverPath.String
|
||||
}
|
||||
|
||||
|
||||
// Already absolute path? Return as-is (backward compatibility)
|
||||
if filepath.IsAbs(coverPath.String) {
|
||||
return coverPath.String
|
||||
}
|
||||
|
||||
|
||||
// Resolve relative path to URL format
|
||||
libraryIDStr := libraryID.Bytes.String()
|
||||
return fmt.Sprintf("/uploads/library-%s/%s", libraryIDStr, coverPath.String)
|
||||
@@ -553,6 +573,7 @@ func (h *CollectionHandler) resolveCoverURL(libraryID pgtype.UUID, coverPath pgt
|
||||
**File**: `internal/handlers/collections.go`
|
||||
|
||||
**Current code** (lines 620-641 in TestRules function):
|
||||
|
||||
```go
|
||||
var matches []BookMatch
|
||||
for _, item := range mediaItems {
|
||||
@@ -579,6 +600,7 @@ for _, item := range mediaItems {
|
||||
```
|
||||
|
||||
**New code**:
|
||||
|
||||
```go
|
||||
var matches []BookMatch
|
||||
for _, item := range mediaItems {
|
||||
@@ -609,6 +631,7 @@ for _, item := range mediaItems {
|
||||
**Location 1 - PreviewCollection function** (lines 910-919):
|
||||
|
||||
**Current code**:
|
||||
|
||||
```go
|
||||
bookCards := make([]BookInfo, len(matchedItems))
|
||||
for i, item := range matchedItems {
|
||||
@@ -623,6 +646,7 @@ for i, item := range matchedItems {
|
||||
```
|
||||
|
||||
**New code**:
|
||||
|
||||
```go
|
||||
bookCards := make([]BookInfo, len(matchedItems))
|
||||
for i, item := range matchedItems {
|
||||
@@ -639,6 +663,7 @@ for i, item := range matchedItems {
|
||||
**Location 2 - mediaItemsToListMediaItemsRow helper** (line 935):
|
||||
|
||||
**Current code**:
|
||||
|
||||
```go
|
||||
func mediaItemsToListMediaItemsRow(item database.MediaItems) database.ListMediaItemsRow {
|
||||
return database.ListMediaItemsRow{
|
||||
@@ -650,6 +675,7 @@ func mediaItemsToListMediaItemsRow(item database.MediaItems) database.ListMediaI
|
||||
```
|
||||
|
||||
**New code**:
|
||||
|
||||
```go
|
||||
// NOTE: This helper function doesn't have access to libraryID
|
||||
// Consider refactoring to pass libraryID or handle URL resolution at call site
|
||||
@@ -663,15 +689,15 @@ func (h *CollectionHandler) resolveFileURL(libraryID pgtype.UUID, filePath pgtyp
|
||||
if !filePath.Valid || filePath.String == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
if strings.HasPrefix(filePath.String, "/uploads/") {
|
||||
return filePath.String
|
||||
}
|
||||
|
||||
|
||||
if filepath.IsAbs(filePath.String) {
|
||||
return filePath.String
|
||||
}
|
||||
|
||||
|
||||
libraryIDStr := libraryID.Bytes.String()
|
||||
return fmt.Sprintf("/uploads/library-%s/%s", libraryIDStr, filePath.String)
|
||||
}
|
||||
@@ -691,15 +717,15 @@ func (h *Handler) resolveCoverURL(libraryID pgtype.UUID, coverPath pgtype.Text)
|
||||
if !coverPath.Valid || coverPath.String == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
if strings.HasPrefix(coverPath.String, "/uploads/") {
|
||||
return coverPath.String
|
||||
}
|
||||
|
||||
|
||||
if filepath.IsAbs(coverPath.String) {
|
||||
return coverPath.String
|
||||
}
|
||||
|
||||
|
||||
libraryIDStr := libraryID.Bytes.String()
|
||||
return fmt.Sprintf("/uploads/library-%s/%s", libraryIDStr, coverPath.String)
|
||||
}
|
||||
@@ -708,6 +734,7 @@ func (h *Handler) resolveCoverURL(libraryID pgtype.UUID, coverPath pgtype.Text)
|
||||
**Location 1 - GetAllProgress function** (lines 286-289):
|
||||
|
||||
**Current code**:
|
||||
|
||||
```go
|
||||
coverPath := ""
|
||||
if mediaItem.CoverImagePath.Valid {
|
||||
@@ -716,6 +743,7 @@ if mediaItem.CoverImagePath.Valid {
|
||||
```
|
||||
|
||||
**New code** (remove the manual resolution, use helper):
|
||||
|
||||
```go
|
||||
coverPath := h.resolveCoverURL(mediaItem.LibraryID, mediaItem.CoverImagePath)
|
||||
```
|
||||
@@ -723,6 +751,7 @@ coverPath := h.resolveCoverURL(mediaItem.LibraryID, mediaItem.CoverImagePath)
|
||||
**Location 2 - GetAllProgressData function** (lines 357-360):
|
||||
|
||||
**Current code**:
|
||||
|
||||
```go
|
||||
coverPath := ""
|
||||
if mediaItem.CoverImagePath.Valid {
|
||||
@@ -731,6 +760,7 @@ if mediaItem.CoverImagePath.Valid {
|
||||
```
|
||||
|
||||
**New code**:
|
||||
|
||||
```go
|
||||
coverPath := h.resolveCoverURL(mediaItem.LibraryID, mediaItem.CoverImagePath)
|
||||
```
|
||||
@@ -742,6 +772,7 @@ coverPath := h.resolveCoverURL(mediaItem.LibraryID, mediaItem.CoverImagePath)
|
||||
**File**: `internal/handlers/media.go`
|
||||
|
||||
Add to imports:
|
||||
|
||||
```go
|
||||
"bookhoard/internal/utils"
|
||||
```
|
||||
@@ -749,11 +780,13 @@ Add to imports:
|
||||
**GetMediaItem** - Find where it returns the response (around line 770):
|
||||
|
||||
**Current code**:
|
||||
|
||||
```go
|
||||
return c.JSON(http.StatusOK, item)
|
||||
```
|
||||
|
||||
**New code**:
|
||||
|
||||
```go
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"id": uuid.UUID(item.ID.Bytes).String(),
|
||||
@@ -781,12 +814,14 @@ Wrap each item in the response with resolved URLs. The exact implementation depe
|
||||
**File**: `web/src/bookshelf.ts`
|
||||
|
||||
**Current code** (line 49-50):
|
||||
|
||||
```typescript
|
||||
${book.cover_image_path ?
|
||||
`<img src="/covers/${book.cover_image_path}" alt="${book.title}" class="w-full h-48 object-cover rounded mb-2">` :
|
||||
```
|
||||
|
||||
**New code**:
|
||||
|
||||
```typescript
|
||||
${book.cover_image_path ?
|
||||
`<img src="${book.cover_image_path}" alt="${book.title}" class="w-full h-48 object-cover rounded mb-2">` :
|
||||
@@ -798,32 +833,34 @@ The backend now returns full URLs like `/uploads/library-{id}/path/to/cover.jpg`
|
||||
|
||||
### Summary of Changes for Phase 7
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `internal/utils/mediaurl.go` | Create with `ResolveMediaURL()` function for URL resolution (one source of truth) |
|
||||
| `internal/handlers/media.go` | Update GetMediaItem and ListMediaItems to use `utils.ResolveMediaURL()` for resolved URLs in responses |
|
||||
| File | Changes |
|
||||
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `internal/utils/mediaurl.go` | Create with `ResolveMediaURL()` function for URL resolution (one source of truth) |
|
||||
| `internal/handlers/media.go` | Update GetMediaItem and ListMediaItems to use `utils.ResolveMediaURL()` for resolved URLs in responses |
|
||||
| `internal/handlers/collections.go` | Use `utils.ResolveMediaURL()` in GetCollection, TestRules, PreviewCollection; update lines 193-201, 620-641, 910-919 |
|
||||
| `internal/handlers/progress.go` | Use `utils.ResolveMediaURL()` in GetAllProgress; update lines 286-289, 357-360 |
|
||||
| `web/src/bookshelf.ts` | Remove `/covers/` prefix from cover image URL |
|
||||
| `internal/handlers/progress.go` | Use `utils.ResolveMediaURL()` in GetAllProgress; update lines 286-289, 357-360 |
|
||||
| `web/src/bookshelf.ts` | Remove `/covers/` prefix from cover image URL |
|
||||
|
||||
---
|
||||
|
||||
### Additional Plan Updates Needed
|
||||
|
||||
| Item | Status |
|
||||
|------|--------|
|
||||
| Add `mi.library_id` to GetCollectionItems SQL query | Needs to be done before implementing Step 2 in collections.go |
|
||||
| Create `internal/utils/mediaurl.go` | Needs to be created before implementing URL resolution |
|
||||
| Update callers to use utils package | Replace h.resolveCoverURL/resolveFileURL with utils.ResolveMediaURL |
|
||||
| Item | Status |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| Add `mi.library_id` to GetCollectionItems SQL query | Needs to be done before implementing Step 2 in collections.go |
|
||||
| Create `internal/utils/mediaurl.go` | Needs to be created before implementing URL resolution |
|
||||
| Update callers to use utils package | Replace h.resolveCoverURL/resolveFileURL with utils.ResolveMediaURL |
|
||||
|
||||
## Phase 8: Backward Compatibility
|
||||
|
||||
Handle existing absolute paths in database:
|
||||
|
||||
### Option A: Migration (One-time)
|
||||
|
||||
Create a script to convert existing absolute paths to relative paths using known library folder paths.
|
||||
|
||||
### Option B: Runtime Resolution (No migration)
|
||||
|
||||
Add backward compatibility in handlers:
|
||||
|
||||
```go
|
||||
@@ -832,7 +869,7 @@ func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UU
|
||||
if filepath.IsAbs(relativePath) {
|
||||
return relativePath, nil
|
||||
}
|
||||
|
||||
|
||||
// Otherwise resolve as relative path
|
||||
return mh.libraryService.ResolveMediaPath(ctx, libraryID, relativePath)
|
||||
}
|
||||
@@ -894,7 +931,7 @@ func TestGetRelativePath(t *testing.T) {
|
||||
scanner := &MediaScanner{
|
||||
folders: []string{"/app/uploads", "/var/books"},
|
||||
}
|
||||
|
||||
|
||||
tests := []struct {
|
||||
absolute string
|
||||
expected string
|
||||
@@ -903,7 +940,7 @@ func TestGetRelativePath(t *testing.T) {
|
||||
{"/var/books/manga/Naruto/vol1", "manga/Naruto/vol1"},
|
||||
{"/other/path/file.pdf", "/other/path/file.pdf"}, // fallback
|
||||
}
|
||||
|
||||
|
||||
for _, tt := range tests {
|
||||
result := scanner.getRelativePath(tt.absolute)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
@@ -949,28 +986,28 @@ info:
|
||||
seq: 1
|
||||
http:
|
||||
method: GET
|
||||
url: '{{base_url}}/uploads/library-{{library_id}}/path/to/cover.jpg'
|
||||
url: "{{base_url}}/uploads/library-{{library_id}}/path/to/cover.jpg"
|
||||
auth: none
|
||||
|
||||
docs: |-
|
||||
## Get Cover Image
|
||||
|
||||
|
||||
Retrieve the cover image for a media item via authenticated static-style URL.
|
||||
Uses JWT authentication (same as API endpoints).
|
||||
|
||||
|
||||
**Method:** GET
|
||||
|
||||
|
||||
**Endpoint:** /uploads/library-{id}/{path}
|
||||
|
||||
|
||||
**Authentication:** Bearer token required
|
||||
|
||||
|
||||
**Response:** Binary image data (JPEG, PNG, etc.)
|
||||
|
||||
|
||||
**Status Codes:**
|
||||
- 200: Success - returns image
|
||||
- 401: Unauthorized (missing/invalid JWT)
|
||||
- 404: File not found
|
||||
|
||||
|
||||
**Note:** The actual path would come from the API response which provides
|
||||
the resolved URL. This test is a template showing the URL format.
|
||||
|
||||
@@ -989,34 +1026,34 @@ info:
|
||||
seq: 1
|
||||
http:
|
||||
method: GET
|
||||
url: '{{base_url}}/uploads/library-{{library_id}}/path/to/book.epub'
|
||||
url: "{{base_url}}/uploads/library-{{library_id}}/path/to/book.epub"
|
||||
auth: none
|
||||
|
||||
docs: |-
|
||||
## Download Media Item
|
||||
|
||||
|
||||
Download a media item file (EPUB, PDF, CBZ, etc.) via authenticated static-style URL.
|
||||
Uses JWT authentication (same as API endpoints).
|
||||
|
||||
|
||||
**Method:** GET
|
||||
|
||||
|
||||
**Endpoint:** /uploads/library-{id}/{path}
|
||||
|
||||
|
||||
**Authentication:** Bearer token required
|
||||
|
||||
|
||||
**Path Resolution:** The handler resolves the relative file path stored in the
|
||||
database against the library's configured folder(s) to locate the actual file.
|
||||
|
||||
|
||||
**Backward Compatibility:** Supports both relative paths (new) and absolute
|
||||
paths (legacy data).
|
||||
|
||||
|
||||
**Response:** Binary file data with appropriate Content-Type header
|
||||
|
||||
|
||||
**Status Codes:**
|
||||
- 200: Success - returns file
|
||||
- 401: Unauthorized (missing/invalid JWT)
|
||||
- 404: File not found
|
||||
|
||||
|
||||
**Note:** The actual path would come from the API response which provides
|
||||
the resolved URL. This test shows the URL format.
|
||||
|
||||
@@ -1030,7 +1067,7 @@ vars:
|
||||
|
||||
### File: `docs/developer/api/media-items/get_cover_image.md`
|
||||
|
||||
```markdown
|
||||
````markdown
|
||||
---
|
||||
title: Get Cover Image
|
||||
description: Retrieve the cover image for a media item
|
||||
@@ -1046,15 +1083,15 @@ Retrieve the cover image for a media item.
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| id | string | The media item ID (UUID) |
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------ | ------------------------ |
|
||||
| id | string | The media item ID (UUID) |
|
||||
|
||||
## Headers
|
||||
|
||||
| Header | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| Authorization | Yes | Bearer token |
|
||||
| Header | Required | Description |
|
||||
| ------------- | -------- | ------------ |
|
||||
| Authorization | Yes | Bearer token |
|
||||
|
||||
## Response
|
||||
|
||||
@@ -1064,7 +1101,7 @@ Retrieve the cover image for a media item.
|
||||
|
||||
- **400 Bad Request**: Invalid media item ID
|
||||
|
||||
- **404 Not Found**:
|
||||
- **404 Not Found**:
|
||||
- Media item not found
|
||||
- No cover image configured
|
||||
- Cover image file not found on disk
|
||||
@@ -1076,6 +1113,7 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||
http://localhost:8765/api/covers/550e8400-e29b-41d4-a716-446655440000 \
|
||||
--output cover.jpg
|
||||
```
|
||||
````
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -1088,6 +1126,7 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||
### File: `docs/developer/api/media-items/download_book.md`
|
||||
|
||||
Update existing documentation to note:
|
||||
|
||||
- File paths are stored relative to library folders
|
||||
- Handler resolves path at request time
|
||||
- Backward compatible with existing absolute paths
|
||||
@@ -1096,29 +1135,29 @@ Update existing documentation to note:
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
| Phase | File | Change |
|
||||
|-------|------|--------|
|
||||
| Refactor | `internal/handlers/commonhandlers.go` | Move Handler struct from scanner.go (kitchen sink handler for progress/book matching) |
|
||||
| Refactor | `internal/routers/*.go` | Update NewHandler instantiation if needed |
|
||||
| 1 | `internal/services/media_scanner.go` | Add `getRelativePath()` function; use for both file_path and cover_path |
|
||||
| 2 | `internal/services/library_service.go` | Add `ResolveMediaPath()` function (one source of truth) |
|
||||
| 3 | `internal/handlers/media.go` | Add `getFullFilePath()` helper that calls service |
|
||||
| 4 | `internal/handlers/media.go` | Modify `DownloadBook` to use `getFullFilePath()` |
|
||||
| 5 | `internal/handlers/media.go` | Add `ServeFile()` handler for authenticated static-style routes |
|
||||
| 5 | `internal/router/media.go` | Add route `GET /uploads/library-:id/*` on Echo (not protected group) |
|
||||
| 6 | `internal/handlers/opds.go` | Add `libraryService` to struct; update `GetCoverImage` to use service |
|
||||
| 7 | `internal/handlers/collections.go` | Add `libraryService` to struct/constructor; resolve cover paths to URLs in API responses |
|
||||
| 7 | `internal/router/*.go` | Update CollectionHandler instantiation to pass LibraryService |
|
||||
| 7 | `internal/handlers/commonhandlers.go` (Handler struct, used by progress.go) | Resolve cover paths to URLs in API responses |
|
||||
| 7 | `internal/handlers/media.go` | Resolve file paths to URLs in API responses |
|
||||
| 7 | `web/src/bookshelf.ts` | Remove `/covers/` prefix (use resolved URL directly) |
|
||||
| 8 | Runtime resolution | Handles both absolute (old) and relative (new) paths |
|
||||
| 9 | `internal/handlers/media_test.go` | Add unit tests for path resolution |
|
||||
| 9 | `internal/services/media_scanner_test.go` | Add unit tests for `getRelativePath()` |
|
||||
| 9 | `cmd/server/tests/cover_file_serving_test.go` | Add integration tests using test_helpers |
|
||||
| 10 | `bruno/media-items/Get Cover Image.yml` | Add Bruno API test |
|
||||
| 10 | `bruno/media-items/EPUB Download.yml` | Update to document relative path handling |
|
||||
| 10 | `docs/developer/api/media-items/` | Update API documentation |
|
||||
| Phase | File | Change |
|
||||
| -------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| Refactor | `internal/handlers/commonhandlers.go` | Move Handler struct from scanner.go (kitchen sink handler for progress/book matching) |
|
||||
| Refactor | `internal/routers/*.go` | Update NewHandler instantiation if needed |
|
||||
| 1 | `internal/services/media_scanner.go` | Add `getRelativePath()` function; use for both file_path and cover_path |
|
||||
| 2 | `internal/services/library_service.go` | Add `ResolveMediaPath()` function (one source of truth) |
|
||||
| 3 | `internal/handlers/media.go` | Add `getFullFilePath()` helper that calls service |
|
||||
| 4 | `internal/handlers/media.go` | Modify `DownloadBook` to use `getFullFilePath()` |
|
||||
| 5 | `internal/handlers/media.go` | Add `ServeFile()` handler for authenticated static-style routes |
|
||||
| 5 | `internal/router/media.go` | Add route `GET /uploads/library-:id/*` on Echo (not protected group) |
|
||||
| 6 | `internal/handlers/opds.go` | Add `libraryService` to struct; update `GetCoverImage` to use service |
|
||||
| 7 | `internal/handlers/collections.go` | Add `libraryService` to struct/constructor; resolve cover paths to URLs in API responses |
|
||||
| 7 | `internal/router/*.go` | Update CollectionHandler instantiation to pass LibraryService |
|
||||
| 7 | `internal/handlers/commonhandlers.go` (Handler struct, used by progress.go) | Resolve cover paths to URLs in API responses |
|
||||
| 7 | `internal/handlers/media.go` | Resolve file paths to URLs in API responses |
|
||||
| 7 | `web/src/bookshelf.ts` | Remove `/covers/` prefix (use resolved URL directly) |
|
||||
| 8 | Runtime resolution | Handles both absolute (old) and relative (new) paths |
|
||||
| 9 | `internal/handlers/media_test.go` | Add unit tests for path resolution |
|
||||
| 9 | `internal/services/media_scanner_test.go` | Add unit tests for `getRelativePath()` |
|
||||
| 9 | `cmd/server/tests/cover_file_serving_test.go` | Add integration tests using test_helpers |
|
||||
| 10 | `bruno/media-items/Get Cover Image.yml` | Add Bruno API test |
|
||||
| 10 | `bruno/media-items/EPUB Download.yml` | Update to document relative path handling |
|
||||
| 10 | `docs/developer/api/media-items/` | Update API documentation |
|
||||
|
||||
---
|
||||
|
||||
@@ -1165,9 +1204,9 @@ Users can configure any mount point in docker-compose:
|
||||
services:
|
||||
bookhoard:
|
||||
volumes:
|
||||
- ./epubs:/app/epubs # ebooks
|
||||
- ./manga:/var/manga # manga
|
||||
- ./comics:/media/comics # comics
|
||||
- ./epubs:/app/epubs # ebooks
|
||||
- ./manga:/var/manga # manga
|
||||
- ./comics:/media/comics # comics
|
||||
```
|
||||
|
||||
The system stores relative paths, so it works with any configuration.
|
||||
|
||||
Reference in New Issue
Block a user