docs: update cover image serving plan
This commit is contained in:
+375
-10
@@ -357,7 +357,7 @@ func (h *OPDSHandler) GetCoverImage(c echo.Context) error {
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: OPDSHandler already has `libraryService` injected, so it can use the same resolution logic.
|
||||
**Note**: OPDSHandler uses `ResolveMediaPath()` to resolve to **filesystem path** (not URL) because OPDS serves files directly from the local filesystem. This is different from API handlers which resolve to `/uploads/library-{id}/...` URLs.
|
||||
|
||||
**If OPDSHandler doesn't have libraryService**, add it:
|
||||
|
||||
@@ -379,15 +379,378 @@ func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryServic
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Frontend - No Changes Needed (SSR)
|
||||
## Phase 7: Update ALL Handlers to Resolve URLs in API Responses
|
||||
|
||||
The existing frontend code should work without modification:
|
||||
Every handler that returns `cover_image_path` or `file_path` in API responses must resolve the relative path to a full URL before sending to the client.
|
||||
|
||||
- **dashboard.templ**: Uses `item.CoverImagePath` directly in SSR
|
||||
- **dashboard.ts**: Uses `book.cover_image_path` from API response
|
||||
- **bookshelf.ts**: Uses `book.cover_image_path` from API response
|
||||
### URL Resolution Flow
|
||||
|
||||
**Note**: Backend resolves URLs when building API responses, so frontend just uses the URL directly - no extra requests.
|
||||
1. **Database stores**: Relative path (e.g., `Author/Book/cover.jpg`)
|
||||
2. **Handler fetches**: Gets relative path from DB (as pgtype.Text)
|
||||
3. **Handler resolves**: Calls `ResolveCoverURL()` → returns `/uploads/library-{id}/Author/Book/cover.jpg`
|
||||
4. **Handler replaces**: Sets resolved URL string in response (not pgtype.Text)
|
||||
5. **Frontend/mobile**: Uses URL directly (authenticated via JWT)
|
||||
|
||||
### Step 1: Add URL Resolution Helper to MediaHandler
|
||||
|
||||
**File**: `internal/handlers/media.go`
|
||||
|
||||
Add these helper methods after the `NewMediaHandler` function (around line 100):
|
||||
|
||||
```go
|
||||
// ResolveCoverURL resolves a relative cover path to a full URL for API responses
|
||||
func (mh *MediaHandler) ResolveCoverURL(libraryID pgtype.UUID, coverPath pgtype.Text) string {
|
||||
if !coverPath.Valid || coverPath.String == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return mh.resolveMediaURL(libraryID, coverPath.String)
|
||||
}
|
||||
|
||||
// ResolveFileURL resolves a relative file path to a full URL for API responses
|
||||
func (mh *MediaHandler) ResolveFileURL(libraryID pgtype.UUID, filePath pgtype.Text) string {
|
||||
if !filePath.Valid || filePath.String == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return mh.resolveMediaURL(libraryID, filePath.String)
|
||||
}
|
||||
|
||||
// resolveMediaURL is the internal helper that does the actual resolution
|
||||
func (mh *MediaHandler) resolveMediaURL(libraryID pgtype.UUID, relativePath string) string {
|
||||
// Already a full URL? Return as-is
|
||||
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)
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Add `"strings"` and `"fmt"` to the imports if not already present.
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Update collections.go - GetCollectionBooks (line ~199)
|
||||
|
||||
**File**: `internal/handlers/collections.go`
|
||||
|
||||
**Current code** (lines 193-201):
|
||||
```go
|
||||
bookList := make([]BookInfo, 0, len(books))
|
||||
for _, book := range books {
|
||||
bookList = append(bookList, BookInfo{
|
||||
MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(),
|
||||
Title: book.Title,
|
||||
Author: textToString(book.Author),
|
||||
CoverImagePath: textToString(book.CoverImagePath),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**New code**:
|
||||
```go
|
||||
bookList := make([]BookInfo, 0, len(books))
|
||||
for _, book := range books {
|
||||
bookList = append(bookList, BookInfo{
|
||||
MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(),
|
||||
Title: book.Title,
|
||||
Author: textToString(book.Author),
|
||||
CoverImagePath: h.resolveCoverURL(book.LibraryID, book.CoverImagePath),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Add helper method** to CollectionHandler struct (near line 10):
|
||||
|
||||
```go
|
||||
// resolveCoverURL resolves a relative cover path to a full URL
|
||||
func (h *CollectionHandler) resolveCoverURL(libraryID pgtype.UUID, coverPath pgtype.Text) string {
|
||||
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)
|
||||
}
|
||||
```
|
||||
|
||||
**Add imports** if not present: `"fmt"`, `"path/filepath"`, `"strings"`
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Update collections.go - CheckMatchRules (line ~626)
|
||||
|
||||
**File**: `internal/handlers/collections.go`
|
||||
|
||||
**Current code** (lines 620-641):
|
||||
```go
|
||||
var matches []BookMatch
|
||||
for _, item := range mediaItems {
|
||||
matchReason := h.checkRulesAgainstBook(item, req.Rules)
|
||||
if matchReason != "" {
|
||||
coverPath := ""
|
||||
if item.CoverImagePath.Valid {
|
||||
coverPath = item.CoverImagePath.String
|
||||
}
|
||||
author := ""
|
||||
if item.Author.Valid {
|
||||
author = item.Author.String
|
||||
}
|
||||
|
||||
matches = append(matches, BookMatch{
|
||||
MediaItemID: uuid.UUID(item.ID.Bytes).String(),
|
||||
Title: item.Title,
|
||||
Author: author,
|
||||
CoverImagePath: coverPath,
|
||||
MatchReason: matchReason,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**New code**:
|
||||
```go
|
||||
var matches []BookMatch
|
||||
for _, item := range mediaItems {
|
||||
matchReason := h.checkRulesAgainstBook(item, req.Rules)
|
||||
if matchReason != "" {
|
||||
author := ""
|
||||
if item.Author.Valid {
|
||||
author = item.Author.String
|
||||
}
|
||||
|
||||
matches = append(matches, BookMatch{
|
||||
MediaItemID: uuid.UUID(item.ID.Bytes).String(),
|
||||
Title: item.Title,
|
||||
Author: author,
|
||||
CoverImagePath: h.resolveCoverURL(item.LibraryID, item.CoverImagePath),
|
||||
MatchReason: matchReason,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Update collections.go - ListCollectionBooks (line ~935)
|
||||
|
||||
**File**: `internal/handlers/collections.go`
|
||||
|
||||
**Current code** (lines 925-954 - the entire function):
|
||||
```go
|
||||
return database.ListMediaItemsRow{
|
||||
ID: item.ID,
|
||||
LibraryID: item.LibraryID,
|
||||
Title: item.Title,
|
||||
Author: item.Author,
|
||||
Isbn: item.Isbn,
|
||||
Description: item.Description,
|
||||
FilePath: item.FilePath,
|
||||
FileSize: item.FileSize,
|
||||
MimeType: item.MimeType,
|
||||
CoverImagePath: item.CoverImagePath,
|
||||
// ... rest of fields
|
||||
}
|
||||
```
|
||||
|
||||
**New code**:
|
||||
```go
|
||||
return database.ListMediaItemsRow{
|
||||
ID: item.ID,
|
||||
LibraryID: item.LibraryID,
|
||||
Title: item.Title,
|
||||
Author: item.Author,
|
||||
Isbn: item.Isbn,
|
||||
Description: item.Description,
|
||||
FilePath: pgtype.Text{String: h.resolveFileURL(item.LibraryID, item.FilePath), Valid: item.FilePath.Valid},
|
||||
FileSize: item.FileSize,
|
||||
MimeType: item.MimeType,
|
||||
CoverImagePath: pgtype.Text{String: h.resolveCoverURL(item.LibraryID, item.CoverImagePath), Valid: item.CoverImagePath.Valid},
|
||||
// ... rest of fields
|
||||
}
|
||||
```
|
||||
|
||||
**Add helper method** for file URL resolution:
|
||||
|
||||
```go
|
||||
// resolveFileURL resolves a relative file path to a full URL
|
||||
func (h *CollectionHandler) resolveFileURL(libraryID pgtype.UUID, filePath pgtype.Text) string {
|
||||
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)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 5: Update progress.go - two locations (lines ~288 and ~359)
|
||||
|
||||
**File**: `internal/handlers/progress.go`
|
||||
|
||||
First, add helper methods to ProgressHandler struct (find struct definition and add after it):
|
||||
|
||||
```go
|
||||
// resolveCoverURL resolves a relative cover path to a full URL
|
||||
func (h *ProgressHandler) resolveCoverURL(libraryID pgtype.UUID, coverPath pgtype.Text) string {
|
||||
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)
|
||||
}
|
||||
```
|
||||
|
||||
**Location 1 - GetReadingProgress function** (around line 286-289):
|
||||
|
||||
**Current code**:
|
||||
```go
|
||||
coverPath := ""
|
||||
if mediaItem.CoverImagePath.Valid {
|
||||
coverPath = mediaItem.CoverImagePath.String
|
||||
}
|
||||
```
|
||||
|
||||
**New code** (remove the manual resolution, use helper):
|
||||
```go
|
||||
coverPath := h.resolveCoverURL(mediaItem.LibraryID, mediaItem.CoverImagePath)
|
||||
```
|
||||
|
||||
**Location 2 - GetAllReadingProgress function** (around line 357-360):
|
||||
|
||||
**Current code**:
|
||||
```go
|
||||
coverPath := ""
|
||||
if mediaItem.CoverImagePath.Valid {
|
||||
coverPath = mediaItem.CoverImagePath.String
|
||||
}
|
||||
```
|
||||
|
||||
**New code**:
|
||||
```go
|
||||
coverPath := h.resolveCoverURL(mediaItem.LibraryID, mediaItem.CoverImagePath)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 6: Update media.go - GetMediaItem and ListMediaItems
|
||||
|
||||
**File**: `internal/handlers/media.go`
|
||||
|
||||
The current implementation returns raw database rows directly. We need to convert them to API-safe responses with resolved URLs.
|
||||
|
||||
**Option A: Quick fix** - Modify the response before returning (lines 609, 620, 639)
|
||||
|
||||
For `ListMediaItems` (around line 609 and 620), add a helper to convert each item:
|
||||
|
||||
```go
|
||||
// Add this function somewhere in media.go
|
||||
func resolveMediaItemCoverAndFile(item database.ListMediaItemsRow) database.ListMediaItemsRow {
|
||||
// This is a placeholder - in practice you'd need to add libraryService to MediaHandler
|
||||
// For now, return as-is. Full implementation requires adding libraryService dependency.
|
||||
return item
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: The `ListMediaItems` and `GetMediaItem` functions currently return raw database rows. To properly resolve URLs, you would need to either:
|
||||
|
||||
1. **Add libraryService to MediaHandler** and call the resolution helpers, OR
|
||||
2. **Create a separate response struct** that converts pgtype.Text to resolved URLs
|
||||
|
||||
For this implementation, the recommended approach is:
|
||||
|
||||
**Modify GetMediaItem** (line 639):
|
||||
|
||||
**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(),
|
||||
"library_id": uuid.UUID(item.LibraryID.Bytes).String(),
|
||||
"title": item.Title,
|
||||
"author": textToString(item.Author),
|
||||
"cover_image_path": mh.ResolveCoverURL(item.LibraryID, item.CoverImagePath),
|
||||
"file_path": mh.ResolveFileURL(item.LibraryID, item.FilePath),
|
||||
// ... add other fields as needed
|
||||
})
|
||||
```
|
||||
|
||||
Similarly for `ListMediaItems`, wrap the results in a map with resolved URLs.
|
||||
|
||||
---
|
||||
|
||||
### Step 7: Fix Frontend /covers/ Prefix
|
||||
|
||||
**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">` :
|
||||
```
|
||||
|
||||
The backend now returns full URLs like `/uploads/library-{id}/path/to/cover.jpg`, so no prefix is needed.
|
||||
|
||||
---
|
||||
|
||||
### Summary of Changes for Phase 7
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `internal/handlers/media.go` | Add `ResolveCoverURL()`, `ResolveFileURL()`, `resolveMediaURL()` helpers |
|
||||
| `internal/handlers/collections.go` | Add `resolveCoverURL()`, `resolveFileURL()` to CollectionHandler; update lines ~199, ~626, ~935 |
|
||||
| `internal/handlers/progress.go` | Add `resolveCoverURL()` to ProgressHandler; update lines ~288, ~359 |
|
||||
| `internal/handlers/media.go` | Update `GetMediaItem` to return resolved URLs in response map |
|
||||
| `web/src/bookshelf.ts` | Remove `/covers/` prefix from cover image URL |
|
||||
|
||||
---
|
||||
|
||||
@@ -417,7 +780,7 @@ func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UU
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Tests
|
||||
## Phase 9: Tests
|
||||
|
||||
### Unit Tests
|
||||
|
||||
@@ -659,7 +1022,6 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||
- Supports backward compatibility with existing absolute paths
|
||||
- Images are cached for 24 hours by clients
|
||||
- All endpoints require authentication (JWT)
|
||||
```
|
||||
|
||||
### File: `docs/developer/api/media-items/download_book.md`
|
||||
|
||||
@@ -681,7 +1043,10 @@ Update existing documentation to note:
|
||||
| 5 | `internal/handlers/media.go` | Add `ServeFile()` handler for authenticated static-style routes |
|
||||
| 5 | `internal/router/media.go` | Add route `GET /uploads/library-:id/*` (register LAST) |
|
||||
| 6 | `internal/handlers/opds.go` | Add `libraryService` to struct; update `GetCoverImage` to use service |
|
||||
| 7 | Frontend files | No changes needed (backend resolves URLs) |
|
||||
| 7 | `internal/handlers/collections.go` | Resolve cover paths to URLs in API responses |
|
||||
| 7 | `internal/handlers/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()` |
|
||||
|
||||
Reference in New Issue
Block a user