From 27c6738e029b0cc354bf17556ced239db237fb87 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 26 Feb 2026 20:43:34 -0500 Subject: [PATCH] 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 --- cover_image_serving_plan.md | 671 ++++++++++++++++++++++-------------- 1 file changed, 407 insertions(+), 264 deletions(-) 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 -${book.title} -``` - -### File: `web/src/bookshelf.ts` - -**Location**: Around line 49 - -**Current**: -```typescript -${book.cover_image_path ? - `${book.title}` : -``` - -**New**: -```typescript -${book.media_item_id ? - `${book.title}` : -``` - -### File: `web/src/custom-section-builder.ts` - -**Location**: Around lines 365 and 495 - -Apply same changes as dashboard.ts. +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.