diff --git a/cover_image_serving_plan.md b/cover_image_serving_plan.md
new file mode 100644
index 0000000..4e3c76f
--- /dev/null
+++ b/cover_image_serving_plan.md
@@ -0,0 +1,539 @@
+# Cover Image Serving - Implementation Plan
+
+## Overview
+
+Fix cover image serving to:
+1. Support multiple library folders in docker compose
+2. Keep covers with books (no hardcoded paths)
+3. Store relative paths in database
+4. Serve images via authenticated API endpoint
+
+## Architecture
+
+### Current Behavior
+- Cover path stored as absolute: `/app/uploads/Jane Austen/Pride and Prejudice/cover.jpg`
+- Frontend uses path directly: `
`
+- No route serves `/app/uploads/*`
+
+### Target Behavior
+- Cover path stored as relative: `Jane Austen/Pride and Prejudice/cover.jpg`
+- Frontend uses API: `
`
+- Handler resolves relative path using library folder base path
+
+---
+
+## Phase 1: Update Scanner to Store Relative Paths
+
+### File: `internal/services/media_scanner.go`
+
+#### Change 1: Store relative path instead of absolute
+
+**Location**: Around lines 514-517, 641-651, 832-837, 1056-1067, 1472
+
+**Current code** (example at line 514-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.getRelativeCoverPath(coverPath)
+ }
+}
+```
+
+#### Change 2: Add helper function to convert absolute to relative path
+
+**Add new function** in `internal/services/media_scanner.go`:
+
+```go
+// getRelativeCoverPath converts absolute cover path to relative path
+// using the library folder base path
+func (s *MediaScanner) getRelativeCoverPath(coverPath string) string {
+ // Get the base folder paths from scanner
+ for _, baseFolder := range s.folders {
+ // Check if cover path is within this base folder
+ if strings.HasPrefix(coverPath, baseFolder) {
+ // Return relative path (without leading slash)
+ relPath := strings.TrimPrefix(coverPath, baseFolder)
+ // Remove leading slash if present
+ relPath = strings.TrimPrefix(relPath, "/")
+ return relPath
+ }
+ }
+ // Fallback: if no match, return as-is (shouldn't happen)
+ return coverPath
+}
+```
+
+**Note**: This requires the scanner to have access to `s.folders`. Verify this field exists and is populated.
+
+#### Change 3: Update force rescan path handling
+
+**Location**: Around line 1472 (in the force rescan/update flow)
+
+Apply same `getRelativeCoverPath()` conversion when updating existing items.
+
+---
+
+## Phase 2: Create Cover Image Serving Endpoint
+
+### File: `internal/handlers/media.go`
+
+#### Add new handler function
+
+```go
+// GetCoverImage handles GET /api/covers/:id
+// Returns the cover image for a media item
+func (mh *MediaHandler) GetCoverImage(c echo.Context) error {
+ mediaID := c.Param("id")
+ mediaUUID, err := uuid.Parse(mediaID)
+ if err != nil {
+ return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
+ }
+
+ // Get media item from database
+ item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
+ if err != nil {
+ if err == pgx.ErrNoRows {
+ return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
+ }
+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ }
+
+ // Get cover image path
+ coverPath := item.CoverImagePath.String
+ if coverPath == "" {
+ return c.JSON(http.StatusNotFound, map[string]string{"error": "cover image not found"})
+ }
+
+ // Resolve full path using library folder
+ fullPath, err := mh.resolveCoverPath(c.Request().Context(), item.LibraryID, item.FilePath.String, coverPath)
+ if err != nil {
+ return c.JSON(http.StatusNotFound, map[string]string{"error": "cover image not found"})
+ }
+
+ // Check if file exists
+ if _, err := os.Stat(fullPath); os.IsNotExist(err) {
+ return c.JSON(http.StatusNotFound, map[string]string{"error": "cover image file not found"})
+ }
+
+ // Determine content type
+ ext := strings.ToLower(filepath.Ext(fullPath))
+ contentType := map[string]string{
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".png": "image/png",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+ }[ext]
+
+ if contentType == "" {
+ contentType = "application/octet-stream"
+ }
+
+ // Serve file
+ c.Response().Header().Set("Content-Type", contentType)
+ c.Response().Header().Set("Cache-Control", "public, max-age=86400") // Cache for 1 day
+ return c.File(fullPath)
+}
+
+// resolveCoverPath resolves the full filesystem path for a cover image
+// Handles both relative paths (new) and absolute paths (backward compatibility)
+func (mh *MediaHandler) resolveCoverPath(ctx context.Context, libraryID pgtype.UUID, filePath, coverPath string) (string, error) {
+ // Check if it's already an absolute path (backward compatibility)
+ if filepath.IsAbs(coverPath) {
+ return coverPath, nil
+ }
+
+ // It's a relative path - need to find the library folder base
+ // Get library folders for this library
+ folders, err := mh.db.GetLibraryFolders(ctx, libraryID)
+ if err != nil || len(folders) == 0 {
+ return "", fmt.Errorf("no library folders found")
+ }
+
+ // The file_path is like: /app/uploads/Jane Austen/Pride and Prejudice/book.epub
+ // The coverPath is like: Jane Austen/Pride and Prejudice/cover.jpg
+ // We need to find which folder the book is in
+
+ // Get the directory of the book file
+ bookDir := filepath.Dir(filePath)
+
+ for _, folder := range folders {
+ folderPath := folder.FolderPath
+ // Check if book is in this folder or subfolder
+ if strings.HasPrefix(bookDir, folderPath) {
+ // Construct full cover path
+ return filepath.Join(folderPath, coverPath), nil
+ }
+ }
+
+ // Fallback: try first folder
+ if len(folders) > 0 {
+ return filepath.Join(folders[0].FolderPath, coverPath), nil
+ }
+
+ return "", fmt.Errorf("could not resolve cover path")
+}
+```
+
+**Required imports** (add if not present):
+```go
+import (
+ "path/filepath"
+ "strings"
+ "os"
+ "fmt"
+)
+```
+
+### File: `internal/router/media.go`
+
+#### Add route registration
+
+**Location**: After existing media routes (around line 17)
+
+```go
+// Cover images
+protected.GET("/covers/:id", cfg.MediaHandler.GetCoverImage)
+```
+
+---
+
+## Phase 3: Update Frontend to Use New Endpoint
+
+### File: `web/src/dashboard.ts`
+
+#### Change 1: Update BookInfo type
+
+**Location**: Around line 230 (renderBookCard function)
+
+**Current code**:
+```typescript
+function renderBookCard(book: BookInfo): string {
+ const coverUrl = book.cover_image_path || "/static/placeholder-book.svg";
+```
+
+**New code**:
+```typescript
+function renderBookCard(book: BookInfo): string {
+ const coverUrl = book.media_item_id
+ ? `/api/covers/${book.media_item_id}`
+ : "/static/placeholder-book.svg";
+```
+
+**Note**: This requires `BookInfo` to have `media_item_id` field. Check if it exists, or use a different field.
+
+#### Change 2: Handle image load errors
+
+The existing `onerror` handler should still work:
+```typescript
+
+```
+
+### File: `web/src/bookshelf.ts`
+
+**Location**: Around line 49
+
+**Current**:
+```typescript
+${book.cover_image_path ?
+ `
` :
+```
+
+**New**:
+```typescript
+${book.media_item_id ?
+ `
` :
+```
+
+### File: `web/src/custom-section-builder.ts`
+
+**Location**: Around lines 365 and 495
+
+Apply same changes as dashboard.ts.
+
+---
+
+## Phase 4: Backward Compatibility (Optional - for existing data)
+
+If existing database entries have absolute paths, create a database migration:
+
+### File: `database/migrations/cover_paths.sql`
+
+```sql
+-- This migration converts existing absolute cover paths to relative paths
+-- Run manually if needed for existing data
+
+-- Note: This is a template - adjust based on actual data
+-- First, backup: CREATE TABLE media_items_backup AS SELECT * FROM media_items;
+
+-- The conversion requires knowing the library folder paths
+-- This is complex and may require manual intervention
+-- Alternative: Just handle both cases in the resolveCoverPath function (already done in Phase 2)
+```
+
+---
+
+## Phase 5: Tests
+
+### Unit Tests
+
+#### File: `internal/handlers/media_test.go`
+
+```go
+package handlers
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// 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
+}
+
+// TestGetCoverImage_FileNotFound tests 404 when cover file doesn't exist
+func TestGetCoverImage_FileNotFound(t *testing.T) {
+ // Create media item with cover path pointing to non-existent file
+ // Verify 404 response
+}
+
+// TestResolveCoverPath_RelativePath tests relative path resolution
+func TestResolveCoverPath_RelativePath(t *testing.T) {
+ // Setup: Create library with folder /app/uploads
+ // Media item with cover_path: "Author/Book/cover.jpg"
+ // file_path: "/app/uploads/Author/Book/book.epub"
+ // Call resolveCoverPath
+ // Verify returns: "/app/uploads/Author/Book/cover.jpg"
+}
+
+// TestResolveCoverPath_AbsolutePath tests backward compatibility with absolute paths
+func TestResolveCoverPath_AbsolutePath(t *testing.T) {
+ // Media item with absolute cover_path: "/app/uploads/Author/Book/cover.jpg"
+ // Call resolveCoverPath
+ // Verify returns same absolute path
+}
+```
+
+### Integration Tests
+
+#### File: `cmd/server/tests/cover_image_test.go`
+
+```go
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestCoverImageEndpoint_Integration tests the full cover image flow
+// Uses test_helpers for server setup
+func TestCoverImageEndpoint_Integration(t *testing.T) {
+ // Setup test server
+ setup := SetupTestServer(t, true)
+ defer setup.Close()
+
+ // Create test library with folder
+ libraryID := createTestLibrary(t, setup)
+
+ // Add library folder pointing to temp directory
+ tempDir := t.TempDir()
+ addLibraryFolder(t, setup, libraryID, tempDir)
+
+ // Create a test book file and cover image
+ authorDir := filepath.Join(tempDir, "Test Author")
+ require.NoError(t, os.MkdirAll(authorDir, 0755))
+
+ bookPath := filepath.Join(authorDir, "Test Book.epub")
+ require.NoError(t, os.WriteFile(bookPath, []byte("test epub content"), 0644))
+
+ coverPath := filepath.Join(authorDir, "cover.jpg")
+ // Create a minimal valid JPEG (1x1 red pixel)
+ minimalJPEG := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12, 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20, 0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, 0x2C, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0xFB, 0xD5, 0xDB, 0x20, 0xA8, 0xF2, 0xFF, 0xD9}
+ require.NoError(t, os.WriteFile(coverPath, minimalJPEG, 0644))
+
+ // Trigger a scan
+ scanURL := fmt.Sprintf("%s/api/libraries/%s/scan", setup.Server.URL, libraryID)
+ req, _ := http.NewRequest("POST", scanURL, bytes.NewBuffer([]byte(`{"force": true}`)))
+ req.Header.Set("Authorization", "Bearer "+setup.Token)
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusAccepted, resp.StatusCode)
+
+ // Wait for scan to complete (poll status or wait)
+ // Note: Need to implement job status polling or wait for job completion
+
+ // Get media items to find our test book
+ mediaURL := fmt.Sprintf("%s/api/libraries/%s/media-items", setup.Server.URL, libraryID)
+ req, _ = http.NewRequest("GET", mediaURL, nil)
+ req.Header.Set("Authorization", "Bearer "+setup.Token)
+
+ mediaResp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, mediaResp.StatusCode)
+
+ // Parse response and extract media item ID
+ // ... (simplified for brevity)
+
+ // Test cover image endpoint
+ coverURL := fmt.Sprintf("%s/api/covers/%s", setup.Server.URL, mediaItemID)
+ req, _ = http.NewRequest("GET", coverURL, nil)
+ req.Header.Set("Authorization", "Bearer "+setup.Token)
+
+ coverResp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, coverResp.StatusCode)
+ require.Equal(t, "image/jpeg", coverResp.Header.Get("Content-Type"))
+}
+
+// TestCoverImageRelativePath_Integration tests that relative paths are stored correctly
+func TestCoverImageRelativePath_Integration(t *testing.T) {
+ // Similar setup as above
+ // After scan, verify cover_image_path in database is relative (not absolute)
+}
+```
+
+---
+
+## Phase 6: 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
+```
+
+---
+
+## Summary of Changes
+
+| Phase | File | Change |
+|-------|------|--------|
+| 1 | `internal/services/media_scanner.go` | Add `getRelativeCoverPath()` function; use it when storing cover paths |
+| 2 | `internal/handlers/media.go` | Add `GetCoverImage` and `resolveCoverPath` functions |
+| 2 | `internal/router/media.go` | Add route `GET /api/covers/:id` |
+| 3 | `web/src/dashboard.ts` | Change to use `/api/covers/{id}` |
+| 3 | `web/src/bookshelf.ts` | Change to use `/api/covers/{id}` |
+| 3 | `web/src/custom-section-builder.ts` | Change to use `/api/covers/{id}` |
+| 5 | `internal/handlers/media_test.go` | Add unit tests |
+| 5 | `cmd/server/tests/cover_image_test.go` | Add integration tests |
+| 6 | `docs/developer/api/media-items/get_cover_image.md` | Add API documentation |
+
+---
+
+## Verification Steps
+
+After implementation:
+
+1. **Test new scan**: Add a new book with cover, verify:
+ - Database `cover_image_path` is relative (e.g., `Author/Book/cover.jpg`)
+ - GET `/api/covers/{id}` returns the image
+
+2. **Test existing data**: For items with absolute paths:
+ - GET `/api/covers/{id}` still works (backward compatibility)
+
+3. **Test multiple folders**:
+ - Create library with 2 folders
+ - Add books to each folder
+ - Verify covers resolve correctly for each
+
+4. **Test frontend**:
+ - Dashboard shows cover images
+ - Bookshelf shows cover images
+ - Custom sections show cover images