Files
bookhoard/cover_image_serving_plan.md
T
john-okeefe 264c37a145 docs: add implementation plan for cover image serving
Add detailed implementation plan for fixing cover image serving to:
- Support multiple library folders in docker compose
- Keep covers with books using relative paths in database
- Serve images via authenticated API endpoint

Plan covers:
- Phase 1: Update scanner to store relative paths
- Phase 2: Create /api/covers/:id endpoint with auth
- Phase 3: Update frontend to use new API endpoint
- Phase 4: Backward compatibility for existing data
- Phase 5-6: Tests and API documentation
2026-02-26 17:34:04 -05:00

18 KiB

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: <img src="${book.cover_image_path}">
  • No route serves /app/uploads/*

Target Behavior

  • Cover path stored as relative: Jane Austen/Pride and Prejudice/cover.jpg
  • Frontend uses API: <img src="/api/covers/{media_item_id}">
  • 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):

if len(coverImage) > 0 && metadata.CoverPath == "" {
    coverPath := path + ".cover.jpg"
    if err := os.WriteFile(coverPath, coverImage, 0644); err == nil {
        metadata.CoverPath = coverPath
    }
}

New code:

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:

// 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

// 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):

import (
    "path/filepath"
    "strings"
    "os"
    "fmt"
)

File: internal/router/media.go

Add route registration

Location: After existing media routes (around line 17)

// 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:

function renderBookCard(book: BookInfo): string {
  const coverUrl = book.cover_image_path || "/static/placeholder-book.svg";

New code:

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:

<img src="${coverUrl}" 
     alt="${book.title}"
     class="w-full h-full object-cover"
     loading="lazy"
     onerror="this.src='/static/placeholder-book.svg'">

File: web/src/bookshelf.ts

Location: Around line 49

Current:

${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:

${book.media_item_id ?
    `<img src="/api/covers/${book.media_item_id}" alt="${book.title}" class="w-full h-48 object-cover rounded mb-2">` :

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

-- 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

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

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

---
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