Files
bookhoard/cover_image_serving_plan.md
T
john-okeefe 4d321528b2 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
2026-02-27 17:06:22 -05:00

1213 lines
38 KiB
Markdown

# Cover & File Serving - Implementation Plan
## 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)
4. Serve everything via authenticated API endpoints
5. Mobile app compatibility (same auth for all requests)
## 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
- Authenticated static-style handler serves files: `/uploads/library-{id}/path/to/file`
- Backend resolves full URLs in API/SSR responses (one source of truth)
- 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
This ensures one source of truth for path resolution.
---
## Phase 1: Update Scanner to Store Relative Paths (Files AND Covers)
### File: `internal/services/media_scanner.go`
#### Change 1: Store relative file path
**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},
```
---
#### Change 2: Store relative cover path
**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"
if err := os.WriteFile(coverPath, coverImage, 0644); err == nil {
metadata.CoverPath = coverPath
}
}
```
**New code**:
```go
if len(coverImage) > 0 && metadata.CoverPath == "" {
coverPath := path + ".cover.jpg"
if err := os.WriteFile(coverPath, coverImage, 0644); err == nil {
// Store relative path - derive from library folder base
metadata.CoverPath = s.getRelativePath(coverPath)
}
}
```
**All locations where metadata.CoverPath is set**:
- Line 517 (main cover)
- Line 645 (sidecar cover)
- Line 651 (sidecar cover alternative)
- Line 1060 (main cover)
- Line 1067 (sidecar cover)
#### Change 3: Add helper function
**Add new function** in `internal/services/media_scanner.go`:
```go
// getRelativePath converts absolute filesystem path to relative path
// using the library folder base path
func (s *MediaScanner) getRelativePath(absolutePath string) string {
// Get the base folder paths from scanner
for _, baseFolder := range s.folders {
// Check if path is within this base folder
if strings.HasPrefix(absolutePath, baseFolder) {
// Return relative path (without leading slash)
relPath := strings.TrimPrefix(absolutePath, baseFolder)
// Remove leading slash if present
relPath = strings.TrimPrefix(relPath, "/")
return relPath
}
}
// Fallback: if no match, return as-is (shouldn't happen)
return absolutePath
}
```
**Note**: This uses `s.folders` which is already populated in the scanner.
#### Change 4: Update force rescan path handling
**Location**: Around line 1472 (in the force rescan/update flow)
Apply same `getRelativePath()` conversion when updating existing items.
---
## Phase 2: Create Path Resolution Helper (Service Layer)
### File: `internal/services/library_service.go` (or new file)
Create a reusable function that resolves relative paths to absolute filesystem paths:
```go
// ResolveMediaPath resolves a relative path to absolute filesystem path
// using the library's configured folder(s)
func (s *LibraryService) ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
// Get library folders for this library
folders, err := s.db.GetLibraryFolders(ctx, libraryID)
if err != nil || len(folders) == 0 {
return "", fmt.Errorf("no library folders found for library")
}
// Try each folder - find one where the relative path makes sense
for _, folder := range folders {
fullPath := filepath.Join(folder.FolderPath, relativePath)
if _, err := os.Stat(fullPath); err == nil {
return fullPath, nil
}
}
// Fallback: use first folder (file might not exist yet during scan)
if len(folders) > 0 {
return filepath.Join(folders[0].FolderPath, relativePath), nil
}
return "", fmt.Errorf("could not resolve path")
}
```
---
## Phase 3: Add URL Resolution Helper to MediaHandler
### Strategy
Use `LibraryService.ResolveMediaPath()` to resolve paths. Add a simple wrapper in the handler for convenience.
### File: `internal/handlers/media.go`
Add helper method that uses the service:
```go
// 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) {
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)
}
```
Note: The handler already has `libraryService` injected, so this just calls through to it.
---
## Phase 4: Update Download Handler to Use Relative Paths
### File: `internal/handlers/media.go`
#### Modify DownloadBook function
**Current code** (line 103-144):
```go
func (h *MediaHandler) DownloadBook(c echo.Context) error {
// ...
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgBookUUID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
}
if _, err := os.Stat(mediaItem.FilePath); os.IsNotExist(err) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found on disk"})
}
file, err := os.Open(mediaItem.FilePath)
// ...
}
```
**New code**:
```go
func (h *MediaHandler) DownloadBook(c echo.Context) error {
// ...
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgBookUUID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
}
// Resolve relative path to absolute filesystem path
fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found on disk"})
}
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found on disk"})
}
file, err := os.Open(fullPath)
// ...
}
```
---
## Phase 5: Create Authenticated File Serving Handler
### File: `internal/handlers/media.go`
Create a single handler that serves both covers and book files:
```go
// ServeFile serves files (covers or books) via /uploads/library-{id}/path
// Requires JWT authentication
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"
if ext == ".jpg" || ext == ".jpeg" {
contentType = "image/jpeg"
} else if ext == ".png" {
contentType = "image/png"
} else if ext == ".webp" {
contentType = "image/webp"
} else if ext == ".epub" {
contentType = "application/epub+zip"
} 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)
}
```
### File: `internal/router/media.go`
**Location**: After existing media routes (NOT in the protected group)
```go
// File serving - authenticated (registered on Echo to avoid /api prefix)
// Note: Must be registered LAST as it's a wildcard route
e.GET("/uploads/library-:id/*", createJWTMiddleware(cfg), 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 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:
```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 7: Update ALL Handlers to Resolve URLs in API Responses
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.
### URL Resolution Flow
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 1b: Add libraryService to CollectionHandler
**File**: `internal/handlers/collections.go`
Add `libraryService` field to CollectionHandler struct (near line 10):
```go
type CollectionHandler struct {
db *database.Queries
collectionService *services.CollectionService
libraryService *services.LibraryService // ADD THIS
connManager *wsync.ConnectionManager
}
```
Update constructor to accept and set libraryService:
```go
func NewCollectionHandler(db *database.Queries, libraryService *services.LibraryService, connManager *wsync.ConnectionManager) *CollectionHandler {
return &CollectionHandler{
db: db,
collectionService: services.NewCollectionService(db),
libraryService: libraryService, // ADD THIS
connManager: connManager,
}
}
```
**Update router** where CollectionHandler is instantiated (likely in router/collections.go or similar):
```go
cfg.CollectionHandler, err = handlers.NewCollectionHandler(cfg.Queries, cfg.LibraryService, cfg.ConnManager)
```
---
### Step 2: Update collections.go - GetCollection handler (line ~193-201)
**File**: `internal/handlers/collections.go`
**Current code** (lines 193-201 in GetCollection function):
```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 - TestRules/BookMatch (lines 620-641)
**File**: `internal/handlers/collections.go`
**Current code** (lines 620-641 in TestRules function):
```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 - PreviewCollection (lines 910-919) and mediaItemsToListMediaItemsRow helper (line 935)
**File**: `internal/handlers/collections.go`
**Location 1 - PreviewCollection function** (lines 910-919):
**Current code**:
```go
bookCards := make([]BookInfo, len(matchedItems))
for i, item := range matchedItems {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards[i] = BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: textToString(item.CoverImagePath),
}
}
```
**New code**:
```go
bookCards := make([]BookInfo, len(matchedItems))
for i, item := range matchedItems {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards[i] = BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: h.resolveCoverURL(item.LibraryID, item.CoverImagePath),
}
}
```
**Location 2 - mediaItemsToListMediaItemsRow helper** (line 935):
**Current code**:
```go
func mediaItemsToListMediaItemsRow(item database.MediaItems) database.ListMediaItemsRow {
return database.ListMediaItemsRow{
// ...
CoverImagePath: item.CoverImagePath,
// ...
}
}
```
**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
```
**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 286-289 and 357-360)
**File**: `internal/handlers/progress.go`
First, add helper methods to Handler struct (defined in `commonhandlers.go`, used by progress.go):
```go
// resolveCoverURL resolves a relative cover path to a full URL
func (h *Handler) 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 - GetAllProgress function** (lines 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 - GetAllProgressData function** (lines 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`
Add to imports:
```go
"bookhoard/internal/utils"
```
**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(),
"library_id": uuid.UUID(item.LibraryID.Bytes).String(),
"title": item.Title,
"author": textToString(item.Author),
"cover_image_path": utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
"file_path": utils.ResolveMediaURL(item.LibraryID, item.FilePath),
"file_size": item.FileSize,
"mime_type": textToString(item.MimeType),
// ... add other fields as needed
})
```
**ListMediaItems** - Find where it returns items (around line 609):
Wrap each item in the response with resolved URLs. The exact implementation depends on how ListMediaItems currently returns data - you may need to build a custom response map similar to GetMediaItem.
**Note**: Unlike collections.go and progress.go where we added helper methods to the handler, here we use the utils package function directly since we've consolidated URL resolution into utils.
---
### 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/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 |
---
### 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 |
## 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
func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
// Already absolute? Use as-is (backward compatibility)
if filepath.IsAbs(relativePath) {
return relativePath, nil
}
// Otherwise resolve as relative path
return mh.libraryService.ResolveMediaPath(ctx, libraryID, relativePath)
}
```
**Recommended**: Option B - no database migration needed, handles both old and new data.
---
## Phase 9: Tests
### Unit Tests
#### File: `internal/handlers/media_test.go`
```go
// TestGetCoverImage_ValidItem tests successful cover image retrieval
func TestGetCoverImage_ValidItem(t *testing.T) {
// Setup test server with mock database
// Create a test cover image file
// Call GetCoverImage
// Verify response has correct Content-Type and status code
}
// TestGetCoverImage_NotFound tests 404 for non-existent media item
func TestGetCoverImage_NotFound(t *testing.T) {
// Call with invalid UUID
// Verify 404 response
}
// TestGetCoverImage_NoCover tests 404 when media item has no cover
func TestGetCoverImage_NoCover(t *testing.T) {
// Create media item with empty cover_image_path
// Verify 404 response
}
// TestGetFullFilePath_RelativePath tests relative path resolution
func TestGetFullFilePath_RelativePath(t *testing.T) {
// Setup: Create library with folder /app/uploads
// Media item with file_path: "Author/Book/book.epub"
// Call getFullFilePath
// Verify returns: "/app/uploads/Author/Book/book.epub"
}
// TestGetFullFilePath_AbsolutePath tests backward compatibility
func TestGetFullFilePath_AbsolutePath(t *testing.T) {
// Media item with absolute file_path
// Verify returns same path
}
```
### Scanner Tests
#### File: `internal/services/media_scanner_test.go`
```go
// TestGetRelativePath tests path conversion
func TestGetRelativePath(t *testing.T) {
scanner := &MediaScanner{
folders: []string{"/app/uploads", "/var/books"},
}
tests := []struct {
absolute string
expected string
}{
{"/app/uploads/Author/Book/epub", "Author/Book/epub"},
{"/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)
}
}
```
### Integration Tests
#### File: `cmd/server/tests/cover_file_serving_test.go`
```go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
```
Note: The integration tests use `setupTestServer(s.T())` from `cmd/server/tests/test_helpers.go` as per PROJECT_GUIDELINES.md requirements.
### Bruno API Tests
Create new Bruno test files for the new endpoints:
#### File: `bruno/media-items/Get Cover Image.yml`
```yaml
info:
name: Get Cover Image
type: http
seq: 1
http:
method: GET
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.
vars:
library_id: ""
```
#### File: `bruno/media-items/EPUB Download.yml` (Update existing)
Update the existing file to note that downloads now work through the same `/uploads/library-{id}/` endpoint:
```yaml
info:
name: Download Media Item
type: http
seq: 1
http:
method: GET
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.
vars:
library_id: ""
```
---
## Phase 9: Documentation
### File: `docs/developer/api/media-items/get_cover_image.md`
````markdown
---
title: Get Cover Image
description: Retrieve the cover image for a media item
---
# Get Cover Image
Retrieve the cover image for a media item.
## Endpoint
`GET /api/covers/:id`
## Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ------------------------ |
| id | string | The media item ID (UUID) |
## Headers
| Header | Required | Description |
| ------------- | -------- | ------------ |
| Authorization | Yes | Bearer token |
## Response
- **200 OK**: Cover image returned
- Content-Type: `image/jpeg`, `image/png`, etc.
- Cache-Control: `public, max-age=86400`
- **400 Bad Request**: Invalid media item ID
- **404 Not Found**:
- Media item not found
- No cover image configured
- Cover image file not found on disk
## Example
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
http://localhost:8765/api/covers/550e8400-e29b-41d4-a716-446655440000 \
--output cover.jpg
```
````
## Notes
- Cover images are stored relative to their library folder
- The API resolves the full path using the library's configured folder(s)
- 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`
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
---
## 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 |
---
## Verification Steps
After implementation:
1. **Test new scan**: Add a new book with cover, verify:
- Database `file_path` is relative (e.g., `Author/Book/book.epub`)
- 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
2. **Test existing data**: For items with absolute paths:
- Downloads still work (backward compatibility)
- Cover images still work (backward compatibility)
3. **Test multiple mount points**:
- Library A with folder `/app/epubs`
- Library B with folder `/var/manga`
- Books in each resolve correctly via their library ID
4. **Test frontend**:
- Dashboard shows cover images (SSR - initial load)
- Library switch works (dynamic - uses resolved URLs)
- Bookshelf shows cover images
- Downloads work
5. **Test mobile app** (future):
- 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
---
## Flexibility for Users
Users can configure any mount point in docker-compose:
```yaml
services:
bookhoard:
volumes:
- ./epubs:/app/epubs # ebooks
- ./manga:/var/manga # manga
- ./comics:/media/comics # comics
```
The system stores relative paths, so it works with any configuration.