diff --git a/cover_image_serving_plan.md b/cover_image_serving_plan.md
index 4e3c76f..76d3ba3 100644
--- a/cover_image_serving_plan.md
+++ b/cover_image_serving_plan.md
@@ -1,32 +1,57 @@
-# Cover Image Serving - Implementation Plan
+# Cover & File 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
+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: `
`
+- 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`
-- Frontend uses API: `
`
- 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`
-#### 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
@@ -46,50 +71,202 @@ 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)
+ 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`:
```go
-// getRelativeCoverPath converts absolute cover path to relative path
+// getRelativePath converts absolute filesystem path to relative 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
for _, baseFolder := range s.folders {
- // Check if cover path is within this base folder
- if strings.HasPrefix(coverPath, baseFolder) {
+ // Check if path is within this base folder
+ if strings.HasPrefix(absolutePath, baseFolder) {
// Return relative path (without leading slash)
- relPath := strings.TrimPrefix(coverPath, baseFolder)
+ 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 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)
-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`
-#### 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
// 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"})
}
- // 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 {
@@ -110,24 +286,21 @@ func (mh *MediaHandler) GetCoverImage(c echo.Context) error {
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)
+ // Resolve relative path to absolute filesystem path
+ fullPath, err := mh.getFullFilePath(c.Request().Context(), item.LibraryID, 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",
@@ -141,171 +314,70 @@ func (mh *MediaHandler) GetCoverImage(c echo.Context) error {
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
+ c.Response().Header().Set("Cache-Control", "public, max-age=86400")
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)
+**Location**: After existing media routes
```go
-// Cover images
+// Cover images - authenticated
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**:
-```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.
+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.
---
-## 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
--- This migration converts existing absolute cover paths to relative paths
--- Run manually if needed for existing data
+### Option B: Runtime Resolution (No migration)
+Add backward compatibility in handlers:
--- 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)
+```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 5: Tests
+## Phase 8: 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
@@ -326,121 +398,156 @@ func TestGetCoverImage_NoCover(t *testing.T) {
// 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) {
+// TestGetFullFilePath_RelativePath tests relative path resolution
+func TestGetFullFilePath_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"
+ // Media item with file_path: "Author/Book/book.epub"
+ // Call getFullFilePath
+ // Verify returns: "/app/uploads/Author/Book/book.epub"
}
-// 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
+// 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_image_test.go`
+#### File: `cmd/server/tests/cover_file_serving_test.go`
```go
package main
import (
- "bytes"
- "fmt"
- "io"
- "mime/multipart"
- "net/http"
- "os"
- "path/filepath"
- "testing"
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "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
-// Uses test_helpers for server setup
-func TestCoverImageEndpoint_Integration(t *testing.T) {
- // Setup test server
- setup := SetupTestServer(t, true)
- defer setup.Close()
+Note: The integration tests use `setupTestServer(s.T())` from `cmd/server/tests/test_helpers.go` as per PROJECT_GUIDELINES.md requirements.
- // Create test library with folder
- libraryID := createTestLibrary(t, setup)
+### Bruno API Tests
- // Add library folder pointing to temp directory
- tempDir := t.TempDir()
- addLibraryFolder(t, setup, libraryID, tempDir)
+Create new Bruno test files for the new endpoints:
- // 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))
+#### File: `bruno/media-items/Get Cover Image.yml`
- // 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)
+```yaml
+info:
+ name: Get Cover Image
+ type: http
+ seq: 1
+http:
+ method: GET
+ url: '{{base_url}}/api/covers/{{media_item_id}}'
+ auth: none
- // 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"))
-}
+docs: |-
+ ## Get Cover Image
+
+ Retrieve the cover image for a media item. Requires authentication.
+
+ **Method:** GET
+
+ **Endpoint:** /api/covers/{id}
+
+ **Authentication:** Bearer token required
+
+ **Response:** Binary image data (JPEG, PNG, etc.)
+
+ **Status Codes:**
+ - 200: Success - returns image
+ - 400: Invalid media item ID
+ - 401: Unauthorized
+ - 404: Media item not found or cover doesn't exist
-// 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)
-}
+Note: Uses `media_item_id` from environment variables.
+```
+
+#### 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`
@@ -497,23 +604,36 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \
- 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 |
|-------|------|--------|
-| 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 |
+| 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 |
+| 3 | `internal/handlers/media.go` | Modify `ListMediaItems` to include resolved URLs if needed |
+| 4 | `internal/handlers/media.go` | Modify `DownloadBook` to resolve relative paths |
+| 5 | `internal/handlers/media.go` | Add `GetCoverImage` function |
+| 5 | `internal/router/media.go` | Add route `GET /api/covers/:id` |
+| 6 | Frontend files | No changes needed (verify in testing) |
+| 7 | Backward compatibility | Runtime resolution handles absolute paths |
+| 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:
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 `/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:
- - GET `/api/covers/{id}` still works (backward compatibility)
+ - Downloads still work (backward compatibility)
+ - Cover images still work (backward compatibility)
-3. **Test multiple folders**:
- - Create library with 2 folders
- - Add books to each folder
- - Verify covers resolve correctly for each
+3. **Test multiple mount points**:
+ - Library A with folder `/app/epubs`
+ - Library B with folder `/var/manga`
+ - Books in each resolve correctly
4. **Test frontend**:
- Dashboard 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.