docs: expand file/cover serving plan with complete implementation guide

Expands the cover image serving plan into a comprehensive file and cover
image serving implementation guide:

- Rename plan to cover both files and cover images
- Add Phase 1: Store relative file paths (not just cover paths)
- Add Phase 2: Create ResolveMediaPath helper in service layer
- Add Phase 3: Modify existing endpoints to return resolved URLs
- Add Phase 4: Update Download handler to use relative paths
- Add Phase 5: Cover image endpoint (consolidated from original)
- Add Phase 6: Frontend changes (no changes needed for SSR)
- Add Phase 7: Backward compatibility for absolute paths
- Add Phase 8: Unit and integration tests
- Add Phase 9: Documentation and Bruno API tests
- Include full code examples for each phase
- Document flexibility for docker-compose mount points
This commit is contained in:
2026-02-26 20:43:34 -05:00
parent 264c37a145
commit 27c6738e02
+407 -264
View File
@@ -1,32 +1,57 @@
# Cover Image Serving - Implementation Plan # Cover & File Serving - Implementation Plan
## Overview ## Overview
Fix cover image serving to: Fix file and cover image serving to support:
1. Support multiple library folders in docker compose 1. Multiple library folders in docker compose (flexible mount points)
2. Keep covers with books (no hardcoded paths) 2. Keep files with books (no hardcoded paths)
3. Store relative paths in database 3. Store relative paths in database (for both files AND covers)
4. Serve images via authenticated API endpoint 4. Serve everything via authenticated API endpoints
5. Mobile app compatibility (same auth for all requests)
## Architecture ## Architecture
### Current Behavior ### 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` - Cover path stored as absolute: `/app/uploads/Jane Austen/Pride and Prejudice/cover.jpg`
- Frontend uses path directly: `<img src="${book.cover_image_path}">` - Frontend uses path directly - doesn't work (browser can't access container paths)
- No route serves `/app/uploads/*` - No route serves `/app/uploads/*`
### Target Behavior ### 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` - 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 - Handler resolves relative path using library folder base path
- All file/cover requests go through authenticated API endpoints
- Mobile app can use same endpoints with JWT auth
### URL Format
To handle same relative paths in different libraries:
```
/api/files/{media_item_id}/content → serves the book file
/api/covers/{media_item_id} → serves the cover image
```
--- ---
## Phase 1: Update Scanner to Store Relative Paths ## Phase 1: Update Scanner to Store Relative Paths (Files AND Covers)
### File: `internal/services/media_scanner.go` ### File: `internal/services/media_scanner.go`
#### Change 1: Store relative path instead of absolute #### Change 1: Store relative file path
**Location**: Where metadata.FilePath is set (multiple locations)
**Current code**:
```go
metadata.FilePath = path // path is absolute like /app/uploads/Author/Book/file.epub
```
**New code**:
```go
metadata.FilePath = s.getRelativePath(path)
```
#### Change 2: Store relative cover path
**Location**: Around lines 514-517, 641-651, 832-837, 1056-1067, 1472 **Location**: Around lines 514-517, 641-651, 832-837, 1056-1067, 1472
@@ -46,50 +71,202 @@ if len(coverImage) > 0 && metadata.CoverPath == "" {
coverPath := path + ".cover.jpg" coverPath := path + ".cover.jpg"
if err := os.WriteFile(coverPath, coverImage, 0644); err == nil { if err := os.WriteFile(coverPath, coverImage, 0644); err == nil {
// Store relative path - derive from library folder base // Store relative path - derive from library folder base
metadata.CoverPath = s.getRelativeCoverPath(coverPath) metadata.CoverPath = s.getRelativePath(coverPath)
} }
} }
``` ```
#### Change 2: Add helper function to convert absolute to relative path #### Change 3: Add helper function
**Add new function** in `internal/services/media_scanner.go`: **Add new function** in `internal/services/media_scanner.go`:
```go ```go
// getRelativeCoverPath converts absolute cover path to relative path // getRelativePath converts absolute filesystem path to relative path
// using the library folder base path // using the library folder base path
func (s *MediaScanner) getRelativeCoverPath(coverPath string) string { func (s *MediaScanner) getRelativePath(absolutePath string) string {
// Get the base folder paths from scanner // Get the base folder paths from scanner
for _, baseFolder := range s.folders { for _, baseFolder := range s.folders {
// Check if cover path is within this base folder // Check if path is within this base folder
if strings.HasPrefix(coverPath, baseFolder) { if strings.HasPrefix(absolutePath, baseFolder) {
// Return relative path (without leading slash) // Return relative path (without leading slash)
relPath := strings.TrimPrefix(coverPath, baseFolder) relPath := strings.TrimPrefix(absolutePath, baseFolder)
// Remove leading slash if present // Remove leading slash if present
relPath = strings.TrimPrefix(relPath, "/") relPath = strings.TrimPrefix(relPath, "/")
return relPath return relPath
} }
} }
// Fallback: if no match, return as-is (shouldn't happen) // Fallback: if no match, return as-is (shouldn't happen)
return coverPath return absolutePath
} }
``` ```
**Note**: This requires the scanner to have access to `s.folders`. Verify this field exists and is populated. **Note**: This uses `s.folders` which is already populated in the scanner.
#### Change 3: Update force rescan path handling #### Change 4: Update force rescan path handling
**Location**: Around line 1472 (in the force rescan/update flow) **Location**: Around line 1472 (in the force rescan/update flow)
Apply same `getRelativeCoverPath()` conversion when updating existing items. Apply same `getRelativePath()` conversion when updating existing items.
--- ---
## Phase 2: Create Cover Image Serving Endpoint ## 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: Modify Existing Endpoints to Return Resolved URLs
### Strategy
Instead of creating new endpoints, modify existing API responses to include resolved/usable URLs. This avoids extra HTTP calls.
### File: `internal/handlers/media.go` ### File: `internal/handlers/media.go`
#### Add new handler function #### Update ListMediaItems response
**Location**: Around line 612-620
Create a wrapper struct or modify the response to include resolved cover URLs:
```go
// MediaItemResponse includes resolved URLs for frontend use
type MediaItemResponse struct {
database.ListMediaItemsRow
CoverURL string `json:"cover_url"`
FileURL string `json:"file_url"`
}
// In ListMediaItems handler:
items, err := mh.db.ListMediaItems(...)
// ... existing code ...
// Transform to response with resolved URLs
response := make([]MediaItemResponse, len(items))
for i, item := range items {
response[i] = MediaItemResponse{
ListMediaItemsRow: item,
CoverURL: mh.resolveCoverURL(item.LibraryID, item.CoverImagePath.String),
}
}
```
#### Add resolve helper methods
```go
// resolveCoverURL returns a usable URL for the cover image
func (mh *MediaHandler) resolveCoverURL(libraryID pgtype.UUID, coverPath string) string {
if coverPath == "" {
return ""
}
// For now, return empty - frontend uses existing cover endpoint
// Later this could return /api/covers/{id} pattern
return ""
}
// getFullFilePath returns the absolute filesystem path for a media item
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
}
// Resolve relative path using library folders
return mh.libraryService.ResolveMediaPath(ctx, libraryID, relativePath)
}
```
---
## 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 Cover Image Endpoint (For Cases Needing Direct Access)
### File: `internal/handlers/media.go`
While Phase 3 handles cover URLs in existing responses, this endpoint is useful for cases where you need direct cover access (e.g., dynamic updates, specific UI components).
```go ```go
// GetCoverImage handles GET /api/covers/:id // GetCoverImage handles GET /api/covers/:id
@@ -101,7 +278,6 @@ func (mh *MediaHandler) GetCoverImage(c echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) 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}) item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil { if err != nil {
if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
@@ -110,24 +286,21 @@ func (mh *MediaHandler) GetCoverImage(c echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
// Get cover image path
coverPath := item.CoverImagePath.String coverPath := item.CoverImagePath.String
if coverPath == "" { if coverPath == "" {
return c.JSON(http.StatusNotFound, map[string]string{"error": "cover image not found"}) return c.JSON(http.StatusNotFound, map[string]string{"error": "cover image not found"})
} }
// Resolve full path using library folder // Resolve relative path to absolute filesystem path
fullPath, err := mh.resolveCoverPath(c.Request().Context(), item.LibraryID, item.FilePath.String, coverPath) fullPath, err := mh.getFullFilePath(c.Request().Context(), item.LibraryID, coverPath)
if err != nil { if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "cover image not found"}) 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) { if _, err := os.Stat(fullPath); os.IsNotExist(err) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "cover image file not found"}) return c.JSON(http.StatusNotFound, map[string]string{"error": "cover image file not found"})
} }
// Determine content type
ext := strings.ToLower(filepath.Ext(fullPath)) ext := strings.ToLower(filepath.Ext(fullPath))
contentType := map[string]string{ contentType := map[string]string{
".jpg": "image/jpeg", ".jpg": "image/jpeg",
@@ -141,171 +314,70 @@ func (mh *MediaHandler) GetCoverImage(c echo.Context) error {
contentType = "application/octet-stream" contentType = "application/octet-stream"
} }
// Serve file
c.Response().Header().Set("Content-Type", contentType) c.Response().Header().Set("Content-Type", contentType)
c.Response().Header().Set("Cache-Control", "public, max-age=86400") // Cache for 1 day c.Response().Header().Set("Cache-Control", "public, max-age=86400")
return c.File(fullPath) 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` ### File: `internal/router/media.go`
#### Add route registration **Location**: After existing media routes
**Location**: After existing media routes (around line 17)
```go ```go
// Cover images // Cover images - authenticated
protected.GET("/covers/:id", cfg.MediaHandler.GetCoverImage) protected.GET("/covers/:id", cfg.MediaHandler.GetCoverImage)
``` ```
--- ---
## Phase 3: Update Frontend to Use New Endpoint ## Phase 6: Frontend - No Changes Needed (SSR)
### File: `web/src/dashboard.ts` The existing frontend code should work without modification:
#### Change 1: Update BookInfo type - **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
**Location**: Around line 230 (renderBookCard function) **Note**: If covers don't load initially, it's because the handler needs to resolve the URL. The frontend code doesn't need to change - the backend just needs to provide usable URLs.
**Current code**: If the endpoint responses include a resolved URL field (like `cover_url`), update the frontend to use that field. Otherwise, the existing `/api/covers/:id` endpoint can be used.
```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
<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**:
```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**:
```typescript
${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) ## Phase 7: Backward Compatibility
If existing database entries have absolute paths, create a database migration: Handle existing absolute paths in database:
### File: `database/migrations/cover_paths.sql` ### Option A: Migration (One-time)
Create a script to convert existing absolute paths to relative paths using known library folder paths.
```sql ### Option B: Runtime Resolution (No migration)
-- This migration converts existing absolute cover paths to relative paths Add backward compatibility in handlers:
-- Run manually if needed for existing data
-- Note: This is a template - adjust based on actual data ```go
-- First, backup: CREATE TABLE media_items_backup AS SELECT * FROM media_items; func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
// Already absolute? Use as-is (backward compatibility)
-- The conversion requires knowing the library folder paths if filepath.IsAbs(relativePath) {
-- This is complex and may require manual intervention return relativePath, nil
-- Alternative: Just handle both cases in the resolveCoverPath function (already done in Phase 2) }
// 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 5: Tests ## Phase 8: Tests
### Unit Tests ### Unit Tests
#### File: `internal/handlers/media_test.go` #### File: `internal/handlers/media_test.go`
```go ```go
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
// TestGetCoverImage_ValidItem tests successful cover image retrieval // TestGetCoverImage_ValidItem tests successful cover image retrieval
func TestGetCoverImage_ValidItem(t *testing.T) { func TestGetCoverImage_ValidItem(t *testing.T) {
// Setup test server with mock database // Setup test server with mock database
@@ -326,121 +398,156 @@ func TestGetCoverImage_NoCover(t *testing.T) {
// Verify 404 response // Verify 404 response
} }
// TestGetCoverImage_FileNotFound tests 404 when cover file doesn't exist // TestGetFullFilePath_RelativePath tests relative path resolution
func TestGetCoverImage_FileNotFound(t *testing.T) { func TestGetFullFilePath_RelativePath(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 // Setup: Create library with folder /app/uploads
// Media item with cover_path: "Author/Book/cover.jpg" // Media item with file_path: "Author/Book/book.epub"
// file_path: "/app/uploads/Author/Book/book.epub" // Call getFullFilePath
// Call resolveCoverPath // Verify returns: "/app/uploads/Author/Book/book.epub"
// Verify returns: "/app/uploads/Author/Book/cover.jpg"
} }
// TestResolveCoverPath_AbsolutePath tests backward compatibility with absolute paths // TestGetFullFilePath_AbsolutePath tests backward compatibility
func TestResolveCoverPath_AbsolutePath(t *testing.T) { func TestGetFullFilePath_AbsolutePath(t *testing.T) {
// Media item with absolute cover_path: "/app/uploads/Author/Book/cover.jpg" // Media item with absolute file_path
// Call resolveCoverPath // Verify returns same path
// Verify returns same absolute 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 ### Integration Tests
#### File: `cmd/server/tests/cover_image_test.go` #### File: `cmd/server/tests/cover_file_serving_test.go`
```go ```go
package main package main
import ( import (
"bytes" "bytes"
"fmt" "encoding/json"
"io" "fmt"
"mime/multipart" "io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
) )
```
// TestCoverImageEndpoint_Integration tests the full cover image flow Note: The integration tests use `setupTestServer(s.T())` from `cmd/server/tests/test_helpers.go` as per PROJECT_GUIDELINES.md requirements.
// 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 ### Bruno API Tests
libraryID := createTestLibrary(t, setup)
// Add library folder pointing to temp directory Create new Bruno test files for the new endpoints:
tempDir := t.TempDir()
addLibraryFolder(t, setup, libraryID, tempDir)
// Create a test book file and cover image #### File: `bruno/media-items/Get Cover Image.yml`
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 ```yaml
scanURL := fmt.Sprintf("%s/api/libraries/%s/scan", setup.Server.URL, libraryID) info:
req, _ := http.NewRequest("POST", scanURL, bytes.NewBuffer([]byte(`{"force": true}`))) name: Get Cover Image
req.Header.Set("Authorization", "Bearer "+setup.Token) type: http
req.Header.Set("Content-Type", "application/json") seq: 1
http:
resp, err := http.DefaultClient.Do(req) method: GET
require.NoError(t, err) url: '{{base_url}}/api/covers/{{media_item_id}}'
require.Equal(t, http.StatusAccepted, resp.StatusCode) auth: none
// Wait for scan to complete (poll status or wait) docs: |-
// Note: Need to implement job status polling or wait for job completion ## Get Cover Image
// Get media items to find our test book Retrieve the cover image for a media item. Requires authentication.
mediaURL := fmt.Sprintf("%s/api/libraries/%s/media-items", setup.Server.URL, libraryID)
req, _ = http.NewRequest("GET", mediaURL, nil) **Method:** GET
req.Header.Set("Authorization", "Bearer "+setup.Token)
**Endpoint:** /api/covers/{id}
mediaResp, err := http.DefaultClient.Do(req)
require.NoError(t, err) **Authentication:** Bearer token required
require.Equal(t, http.StatusOK, mediaResp.StatusCode)
**Response:** Binary image data (JPEG, PNG, etc.)
// Parse response and extract media item ID
// ... (simplified for brevity) **Status Codes:**
- 200: Success - returns image
// Test cover image endpoint - 400: Invalid media item ID
coverURL := fmt.Sprintf("%s/api/covers/%s", setup.Server.URL, mediaItemID) - 401: Unauthorized
req, _ = http.NewRequest("GET", coverURL, nil) - 404: Media item not found or cover doesn't exist
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 Note: Uses `media_item_id` from environment variables.
func TestCoverImageRelativePath_Integration(t *testing.T) { ```
// Similar setup as above
// After scan, verify cover_image_path in database is relative (not absolute) #### File: `bruno/media-items/Download Media Item.yml` (Update existing)
}
Update the existing file to document that it now handles relative paths:
```yaml
info:
name: Download Media Item
type: http
seq: 1
http:
method: GET
url: '{{base_url}}/api/media-items/{{media_item_id}}/download'
auth: none
docs: |-
## Download Media Item
Download a media item file (EPUB, PDF, CBZ, etc.) from Bookhoard server.
**Method:** GET
**Endpoint:** /api/media-items/{id}/download
**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
- 400: Invalid media item ID
- 401: Unauthorized
- 404: Media item not found or file doesn't exist on disk
Note: Uses `media_item_id` from environment variables.
``` ```
--- ---
## Phase 6: Documentation ## Phase 9: Documentation
### File: `docs/developer/api/media-items/get_cover_image.md` ### File: `docs/developer/api/media-items/get_cover_image.md`
@@ -497,23 +604,36 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \
- The API resolves the full path using the library's configured folder(s) - The API resolves the full path using the library's configured folder(s)
- Supports backward compatibility with existing absolute paths - Supports backward compatibility with existing absolute paths
- Images are cached for 24 hours by clients - 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 ## Summary of Changes
| Phase | File | Change | | Phase | File | Change |
|-------|------|--------| |-------|------|--------|
| 1 | `internal/services/media_scanner.go` | Add `getRelativeCoverPath()` function; use it when storing cover paths | | 1 | `internal/services/media_scanner.go` | Add `getRelativePath()` function; use for both file_path and cover_path |
| 2 | `internal/handlers/media.go` | Add `GetCoverImage` and `resolveCoverPath` functions | | 2 | `internal/services/library_service.go` | Add `ResolveMediaPath()` function |
| 2 | `internal/router/media.go` | Add route `GET /api/covers/:id` | | 3 | `internal/handlers/media.go` | Modify `ListMediaItems` to include resolved URLs if needed |
| 3 | `web/src/dashboard.ts` | Change to use `/api/covers/{id}` | | 4 | `internal/handlers/media.go` | Modify `DownloadBook` to resolve relative paths |
| 3 | `web/src/bookshelf.ts` | Change to use `/api/covers/{id}` | | 5 | `internal/handlers/media.go` | Add `GetCoverImage` function |
| 3 | `web/src/custom-section-builder.ts` | Change to use `/api/covers/{id}` | | 5 | `internal/router/media.go` | Add route `GET /api/covers/:id` |
| 5 | `internal/handlers/media_test.go` | Add unit tests | | 6 | Frontend files | No changes needed (verify in testing) |
| 5 | `cmd/server/tests/cover_image_test.go` | Add integration tests | | 7 | Backward compatibility | Runtime resolution handles absolute paths |
| 6 | `docs/developer/api/media-items/get_cover_image.md` | Add API documentation | | 8 | `internal/handlers/media_test.go` | Add unit tests for GetCoverImage and path resolution |
| 8 | `internal/services/media_scanner_test.go` | Add unit tests for getRelativePath |
| 8 | `cmd/server/tests/cover_file_serving_test.go` | Add integration tests using test_helpers |
| 9 | `bruno/media-items/Get Cover Image.yml` | Add Bruno API test for cover endpoint |
| 9 | `bruno/media-items/EPUB Download.yml` | Update to document relative path handling |
| 9 | `docs/developer/api/media-items/get_cover_image.md` | Add API documentation |
--- ---
@@ -522,18 +642,41 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \
After implementation: After implementation:
1. **Test new scan**: Add a new book with cover, verify: 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`) - Database `cover_image_path` is relative (e.g., `Author/Book/cover.jpg`)
- GET `/api/covers/{id}` returns the image - GET `/api/media-items/:id/download` returns the file
- GET `/api/covers/:id` returns the image
2. **Test existing data**: For items with absolute paths: 2. **Test existing data**: For items with absolute paths:
- GET `/api/covers/{id}` still works (backward compatibility) - Downloads still work (backward compatibility)
- Cover images still work (backward compatibility)
3. **Test multiple folders**: 3. **Test multiple mount points**:
- Create library with 2 folders - Library A with folder `/app/epubs`
- Add books to each folder - Library B with folder `/var/manga`
- Verify covers resolve correctly for each - Books in each resolve correctly
4. **Test frontend**: 4. **Test frontend**:
- Dashboard shows cover images - Dashboard shows cover images
- Bookshelf shows cover images - Bookshelf shows cover images
- Custom sections show cover images - Downloads work
5. **Test mobile app** (future):
- Same JWT auth works for files and covers
---
## 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.