docs: refactor cover image serving plan with unified URL strategy
This commit updates the cover image serving plan to use a more streamlined,
universal approach for file serving across all clients.
Key changes to the plan:
- Adopt unified URL format `/uploads/library-{id}/relative/path` for both
covers and book files, replacing separate /api/files and /api/covers endpoints
- Centralize path resolution through LibraryService.ResolveMediaPath() as the
single source of truth for all handlers
- Consolidate file serving into one authenticated ServeFile handler that
works for web, mobile, and device clients
- Update OPDS handler integration to use the same resolution logic
- Reorganize implementation phases to reflect the unified architecture
Benefits of this approach:
- Simpler routing with one wildcard handler instead of multiple endpoints
- Consistent path resolution logic across MediaHandler, OPDSHandler, and
future handlers
- Better support for multiple libraries and mount points
- Single authentication flow for all file access
- Easier maintenance and testing with centralized resolution
This plan change does not modify any implementation code, only the
documentation for the intended implementation.
This commit is contained in:
+162
-109
@@ -21,15 +21,26 @@ Fix file and cover image serving to support:
|
|||||||
- File path stored as relative: `Jane Austen/Pride and Prejudice/book.epub`
|
- File path stored as relative: `Jane Austen/Pride and Prejudice/book.epub`
|
||||||
- Cover path stored as relative: `Jane Austen/Pride and Prejudice/cover.jpg`
|
- Cover path stored as relative: `Jane Austen/Pride and Prejudice/cover.jpg`
|
||||||
- Handler resolves relative path using library folder base path
|
- Handler resolves relative path using library folder base path
|
||||||
- All file/cover requests go through authenticated API endpoints
|
- Authenticated static-style handler serves files: `/uploads/library-{id}/path/to/file`
|
||||||
- Mobile app can use same endpoints with JWT auth
|
- Backend resolves full URLs in API/SSR responses (one source of truth)
|
||||||
|
- Works with mobile apps, Kobo, KOReader devices via same endpoints
|
||||||
|
|
||||||
### URL Format
|
### URL Format
|
||||||
To handle same relative paths in different libraries:
|
To handle same relative paths in different libraries, use:
|
||||||
```
|
```
|
||||||
/api/files/{media_item_id}/content → serves the book file
|
/uploads/library-{library_id}/relative/path
|
||||||
/api/covers/{media_item_id} → serves the cover image
|
|
||||||
```
|
```
|
||||||
|
- 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
|
||||||
|
|
||||||
|
This ensures one source of truth for path resolution.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -145,56 +156,19 @@ func (s *LibraryService) ResolveMediaPath(ctx context.Context, libraryID pgtype.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 3: Modify Existing Endpoints to Return Resolved URLs
|
## Phase 3: Add URL Resolution Helper to MediaHandler
|
||||||
|
|
||||||
### Strategy
|
### Strategy
|
||||||
|
|
||||||
Instead of creating new endpoints, modify existing API responses to include resolved/usable URLs. This avoids extra HTTP calls.
|
Use `LibraryService.ResolveMediaPath()` to resolve paths. Add a simple wrapper in the handler for convenience.
|
||||||
|
|
||||||
### File: `internal/handlers/media.go`
|
### File: `internal/handlers/media.go`
|
||||||
|
|
||||||
#### Update ListMediaItems response
|
Add helper method that uses the service:
|
||||||
|
|
||||||
**Location**: Around line 612-620
|
|
||||||
|
|
||||||
Create a wrapper struct or modify the response to include resolved cover URLs:
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// MediaItemResponse includes resolved URLs for frontend use
|
|
||||||
type MediaItemResponse struct {
|
|
||||||
database.ListMediaItemsRow
|
|
||||||
CoverURL string `json:"cover_url"`
|
|
||||||
FileURL string `json:"file_url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// In ListMediaItems handler:
|
|
||||||
items, err := mh.db.ListMediaItems(...)
|
|
||||||
// ... existing code ...
|
|
||||||
|
|
||||||
// Transform to response with resolved URLs
|
|
||||||
response := make([]MediaItemResponse, len(items))
|
|
||||||
for i, item := range items {
|
|
||||||
response[i] = MediaItemResponse{
|
|
||||||
ListMediaItemsRow: item,
|
|
||||||
CoverURL: mh.resolveCoverURL(item.LibraryID, item.CoverImagePath.String),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Add resolve helper methods
|
|
||||||
|
|
||||||
```go
|
|
||||||
// resolveCoverURL returns a usable URL for the cover image
|
|
||||||
func (mh *MediaHandler) resolveCoverURL(libraryID pgtype.UUID, coverPath string) string {
|
|
||||||
if coverPath == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
// For now, return empty - frontend uses existing cover endpoint
|
|
||||||
// Later this could return /api/covers/{id} pattern
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// getFullFilePath returns the absolute filesystem path for a media item
|
// getFullFilePath returns the absolute filesystem path for a media item
|
||||||
|
// Uses LibraryService for resolution (one source of truth)
|
||||||
func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
|
func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
|
||||||
if relativePath == "" {
|
if relativePath == "" {
|
||||||
return "", fmt.Errorf("no file path")
|
return "", fmt.Errorf("no file path")
|
||||||
@@ -205,11 +179,13 @@ func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UU
|
|||||||
return relativePath, nil
|
return relativePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve relative path using library folders
|
// Use service for resolution (one source of truth)
|
||||||
return mh.libraryService.ResolveMediaPath(ctx, libraryID, relativePath)
|
return mh.libraryService.ResolveMediaPath(ctx, libraryID, relativePath)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note: The handler already has `libraryService` injected, so this just calls through to it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 4: Update Download Handler to Use Relative Paths
|
## Phase 4: Update Download Handler to Use Relative Paths
|
||||||
@@ -262,58 +238,59 @@ func (h *MediaHandler) DownloadBook(c echo.Context) error {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 5: Create Cover Image Endpoint (For Cases Needing Direct Access)
|
## Phase 5: Create Authenticated File Serving Handler
|
||||||
|
|
||||||
### File: `internal/handlers/media.go`
|
### File: `internal/handlers/media.go`
|
||||||
|
|
||||||
While Phase 3 handles cover URLs in existing responses, this endpoint is useful for cases where you need direct cover access (e.g., dynamic updates, specific UI components).
|
Create a single handler that serves both covers and book files:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// GetCoverImage handles GET /api/covers/:id
|
// ServeFile serves files (covers or books) via /uploads/library-{id}/path
|
||||||
// Returns the cover image for a media item
|
// Requires JWT authentication
|
||||||
func (mh *MediaHandler) GetCoverImage(c echo.Context) error {
|
func (mh *MediaHandler) ServeFile(c echo.Context) error {
|
||||||
mediaID := c.Param("id")
|
// URL format: /uploads/library-{libraryID}/{relativePath}
|
||||||
mediaUUID, err := uuid.Parse(mediaID)
|
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 {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library ID"})
|
||||||
}
|
}
|
||||||
|
|
||||||
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
relativePath := parts[1]
|
||||||
|
|
||||||
|
// Resolve using service
|
||||||
|
fullPath, err := mh.getFullFilePath(c.Request().Context(), pgtype.UUID{Bytes: libraryUUID, Valid: true}, relativePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
coverPath := item.CoverImagePath.String
|
// Check if file exists
|
||||||
if coverPath == "" {
|
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "cover image not found"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve relative path to absolute filesystem path
|
|
||||||
fullPath, err := mh.getFullFilePath(c.Request().Context(), item.LibraryID, coverPath)
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "cover image not found"})
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "cover image file not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine content type
|
||||||
ext := strings.ToLower(filepath.Ext(fullPath))
|
ext := strings.ToLower(filepath.Ext(fullPath))
|
||||||
contentType := map[string]string{
|
contentType := "application/octet-stream"
|
||||||
".jpg": "image/jpeg",
|
if ext == ".jpg" || ext == ".jpeg" {
|
||||||
".jpeg": "image/jpeg",
|
contentType = "image/jpeg"
|
||||||
".png": "image/png",
|
} else if ext == ".png" {
|
||||||
".gif": "image/gif",
|
contentType = "image/png"
|
||||||
".webp": "image/webp",
|
} else if ext == ".webp" {
|
||||||
}[ext]
|
contentType = "image/webp"
|
||||||
|
} else if ext == ".epub" {
|
||||||
if contentType == "" {
|
contentType = "application/epub+zip"
|
||||||
contentType = "application/octet-stream"
|
} else if ext == ".pdf" {
|
||||||
|
contentType = "application/pdf"
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Response().Header().Set("Content-Type", contentType)
|
c.Response().Header().Set("Content-Type", contentType)
|
||||||
c.Response().Header().Set("Cache-Control", "public, max-age=86400")
|
c.Response().Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
return c.File(fullPath)
|
return c.File(fullPath)
|
||||||
@@ -325,13 +302,84 @@ func (mh *MediaHandler) GetCoverImage(c echo.Context) error {
|
|||||||
**Location**: After existing media routes
|
**Location**: After existing media routes
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// Cover images - authenticated
|
// File serving - authenticated
|
||||||
protected.GET("/covers/:id", cfg.MediaHandler.GetCoverImage)
|
// Note: Must be registered LAST as it's a wildcard route
|
||||||
|
protected.GET("/uploads/library-:id/*", cfg.MediaHandler.ServeFile)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important**: This route must be registered LAST because `/*` is a wildcard that matches everything.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Update OPDS Handler for Device Support
|
||||||
|
|
||||||
|
### File: `internal/handlers/opds.go`
|
||||||
|
|
||||||
|
#### 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)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: OPDSHandler already has `libraryService` injected, so it can use the same resolution logic.
|
||||||
|
|
||||||
|
**If OPDSHandler doesn't have libraryService**, add it:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type OPDSHandler struct {
|
||||||
|
db *database.Queries
|
||||||
|
libraryService *services.LibraryService
|
||||||
|
conversionService interface {...}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryService, conversionService ...) *OPDSHandler {
|
||||||
|
return &OPDSHandler{
|
||||||
|
db: db,
|
||||||
|
libraryService: libraryService,
|
||||||
|
conversionService: conversionService,
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 6: Frontend - No Changes Needed (SSR)
|
## Phase 7: Frontend - No Changes Needed (SSR)
|
||||||
|
|
||||||
The existing frontend code should work without modification:
|
The existing frontend code should work without modification:
|
||||||
|
|
||||||
@@ -339,13 +387,11 @@ The existing frontend code should work without modification:
|
|||||||
- **dashboard.ts**: Uses `book.cover_image_path` from API response
|
- **dashboard.ts**: Uses `book.cover_image_path` from API response
|
||||||
- **bookshelf.ts**: Uses `book.cover_image_path` from API response
|
- **bookshelf.ts**: Uses `book.cover_image_path` from API response
|
||||||
|
|
||||||
**Note**: If covers don't load initially, it's because the handler needs to resolve the URL. The frontend code doesn't need to change - the backend just needs to provide usable URLs.
|
**Note**: Backend resolves URLs when building API responses, so frontend just uses the URL directly - no extra requests.
|
||||||
|
|
||||||
If the endpoint responses include a resolved URL field (like `cover_url`), update the frontend to use that field. Otherwise, the existing `/api/covers/:id` endpoint can be used.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 7: Backward Compatibility
|
## Phase 8: Backward Compatibility
|
||||||
|
|
||||||
Handle existing absolute paths in database:
|
Handle existing absolute paths in database:
|
||||||
|
|
||||||
@@ -621,19 +667,20 @@ Update existing documentation to note:
|
|||||||
| Phase | File | Change |
|
| Phase | File | Change |
|
||||||
|-------|------|--------|
|
|-------|------|--------|
|
||||||
| 1 | `internal/services/media_scanner.go` | Add `getRelativePath()` function; use for both file_path and cover_path |
|
| 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 |
|
| 2 | `internal/services/library_service.go` | Add `ResolveMediaPath()` function (one source of truth) |
|
||||||
| 3 | `internal/handlers/media.go` | Modify `ListMediaItems` to include resolved URLs if needed |
|
| 3 | `internal/handlers/media.go` | Add `getFullFilePath()` helper that calls service |
|
||||||
| 4 | `internal/handlers/media.go` | Modify `DownloadBook` to resolve relative paths |
|
| 4 | `internal/handlers/media.go` | Modify `DownloadBook` to use `getFullFilePath()` |
|
||||||
| 5 | `internal/handlers/media.go` | Add `GetCoverImage` function |
|
| 5 | `internal/handlers/media.go` | Add `ServeFile()` handler for authenticated static-style routes |
|
||||||
| 5 | `internal/router/media.go` | Add route `GET /api/covers/:id` |
|
| 5 | `internal/router/media.go` | Add route `GET /uploads/library-:id/*` (register LAST) |
|
||||||
| 6 | Frontend files | No changes needed (verify in testing) |
|
| 6 | `internal/handlers/opds.go` | Add `libraryService` to struct; update `GetCoverImage` to use service |
|
||||||
| 7 | Backward compatibility | Runtime resolution handles absolute paths |
|
| 7 | Frontend files | No changes needed (backend resolves URLs) |
|
||||||
| 8 | `internal/handlers/media_test.go` | Add unit tests for GetCoverImage and path resolution |
|
| 8 | Runtime resolution | Handles both absolute (old) and relative (new) paths |
|
||||||
| 8 | `internal/services/media_scanner_test.go` | Add unit tests for getRelativePath |
|
| 9 | `internal/handlers/media_test.go` | Add unit tests for path resolution |
|
||||||
| 8 | `cmd/server/tests/cover_file_serving_test.go` | Add integration tests using test_helpers |
|
| 9 | `internal/services/media_scanner_test.go` | Add unit tests for `getRelativePath()` |
|
||||||
| 9 | `bruno/media-items/Get Cover Image.yml` | Add Bruno API test for cover endpoint |
|
| 9 | `cmd/server/tests/cover_file_serving_test.go` | Add integration tests using test_helpers |
|
||||||
| 9 | `bruno/media-items/EPUB Download.yml` | Update to document relative path handling |
|
| 10 | `bruno/media-items/Get Cover Image.yml` | Add Bruno API test |
|
||||||
| 9 | `docs/developer/api/media-items/get_cover_image.md` | Add API documentation |
|
| 10 | `bruno/media-items/EPUB Download.yml` | Update to document relative path handling |
|
||||||
|
| 10 | `docs/developer/api/media-items/` | Update API documentation |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -644,8 +691,8 @@ After implementation:
|
|||||||
1. **Test new scan**: Add a new book with cover, verify:
|
1. **Test new scan**: Add a new book with cover, verify:
|
||||||
- Database `file_path` is relative (e.g., `Author/Book/book.epub`)
|
- Database `file_path` is relative (e.g., `Author/Book/book.epub`)
|
||||||
- Database `cover_image_path` is relative (e.g., `Author/Book/cover.jpg`)
|
- Database `cover_image_path` is relative (e.g., `Author/Book/cover.jpg`)
|
||||||
|
- GET `/uploads/library-{id}/Author/Book/cover.jpg` returns the image
|
||||||
- GET `/api/media-items/:id/download` returns the file
|
- GET `/api/media-items/:id/download` returns the file
|
||||||
- GET `/api/covers/:id` returns the image
|
|
||||||
|
|
||||||
2. **Test existing data**: For items with absolute paths:
|
2. **Test existing data**: For items with absolute paths:
|
||||||
- Downloads still work (backward compatibility)
|
- Downloads still work (backward compatibility)
|
||||||
@@ -654,15 +701,21 @@ After implementation:
|
|||||||
3. **Test multiple mount points**:
|
3. **Test multiple mount points**:
|
||||||
- Library A with folder `/app/epubs`
|
- Library A with folder `/app/epubs`
|
||||||
- Library B with folder `/var/manga`
|
- Library B with folder `/var/manga`
|
||||||
- Books in each resolve correctly
|
- Books in each resolve correctly via their library ID
|
||||||
|
|
||||||
4. **Test frontend**:
|
4. **Test frontend**:
|
||||||
- Dashboard shows cover images
|
- Dashboard shows cover images (SSR - initial load)
|
||||||
|
- Library switch works (dynamic - uses resolved URLs)
|
||||||
- Bookshelf shows cover images
|
- Bookshelf shows cover images
|
||||||
- Downloads work
|
- Downloads work
|
||||||
|
|
||||||
5. **Test mobile app** (future):
|
5. **Test mobile app** (future):
|
||||||
- Same JWT auth works for files and covers
|
- Same JWT auth works for files and covers
|
||||||
|
- `/uploads/library-{id}/...` URLs work
|
||||||
|
|
||||||
|
6. **Test device integration**:
|
||||||
|
- Kobo devices can fetch cover images via OPDS
|
||||||
|
- KOReader sync continues to work
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user