# 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 ${book.title} ``` ### File: `web/src/bookshelf.ts` **Location**: Around line 49 **Current**: ```typescript ${book.cover_image_path ? `${book.title}` : ``` **New**: ```typescript ${book.media_item_id ? `${book.title}` : ``` ### 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