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:
+407
-264
@@ -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: `<img src="${book.cover_image_path}">`
|
||||
- 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: `<img src="/api/covers/{media_item_id}">`
|
||||
- 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
|
||||
<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.
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user