This commit updates the cover image serving plan to use a more streamlined,
universal approach for file serving across all clients.
Key changes to the plan:
- Adopt unified URL format `/uploads/library-{id}/relative/path` for both
covers and book files, replacing separate /api/files and /api/covers endpoints
- Centralize path resolution through LibraryService.ResolveMediaPath() as the
single source of truth for all handlers
- Consolidate file serving into one authenticated ServeFile handler that
works for web, mobile, and device clients
- Update OPDS handler integration to use the same resolution logic
- Reorganize implementation phases to reflect the unified architecture
Benefits of this approach:
- Simpler routing with one wildcard handler instead of multiple endpoints
- Consistent path resolution logic across MediaHandler, OPDSHandler, and
future handlers
- Better support for multiple libraries and mount points
- Single authentication flow for all file access
- Easier maintenance and testing with centralized resolution
This plan change does not modify any implementation code, only the
documentation for the intended implementation.
736 lines
21 KiB
Markdown
736 lines
21 KiB
Markdown
# Cover & File Serving - Implementation Plan
|
|
|
|
## Overview
|
|
|
|
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 - 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`
|
|
- Handler resolves relative path using library folder base path
|
|
- Authenticated static-style handler serves files: `/uploads/library-{id}/path/to/file`
|
|
- Backend resolves full URLs in API/SSR responses (one source of truth)
|
|
- Works with mobile apps, Kobo, KOReader devices via same endpoints
|
|
|
|
### URL Format
|
|
To handle same relative paths in different libraries, use:
|
|
```
|
|
/uploads/library-{library_id}/relative/path
|
|
```
|
|
- Requires JWT authentication (like API endpoints)
|
|
- Works for both covers and book files
|
|
- Single handler handles all file serving
|
|
|
|
### Universal Path Resolution
|
|
All handlers use the same `LibraryService.ResolveMediaPath()` function:
|
|
- MediaHandler (downloads)
|
|
- OPDSHandler (device cover images)
|
|
- Future handlers
|
|
|
|
This ensures one source of truth for path resolution.
|
|
|
|
---
|
|
|
|
## Phase 1: Update Scanner to Store Relative Paths (Files AND Covers)
|
|
|
|
### File: `internal/services/media_scanner.go`
|
|
|
|
#### 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
|
|
|
|
**Current code** (example at line 514-517):
|
|
```go
|
|
if len(coverImage) > 0 && metadata.CoverPath == "" {
|
|
coverPath := path + ".cover.jpg"
|
|
if err := os.WriteFile(coverPath, coverImage, 0644); err == nil {
|
|
metadata.CoverPath = coverPath
|
|
}
|
|
}
|
|
```
|
|
|
|
**New code**:
|
|
```go
|
|
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.getRelativePath(coverPath)
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Change 3: Add helper function
|
|
|
|
**Add new function** in `internal/services/media_scanner.go`:
|
|
|
|
```go
|
|
// getRelativePath converts absolute filesystem path to relative path
|
|
// using the library folder base path
|
|
func (s *MediaScanner) getRelativePath(absolutePath string) string {
|
|
// Get the base folder paths from scanner
|
|
for _, baseFolder := range s.folders {
|
|
// Check if path is within this base folder
|
|
if strings.HasPrefix(absolutePath, baseFolder) {
|
|
// Return relative path (without leading slash)
|
|
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 absolutePath
|
|
}
|
|
```
|
|
|
|
**Note**: This uses `s.folders` which is already populated in the scanner.
|
|
|
|
#### Change 4: Update force rescan path handling
|
|
|
|
**Location**: Around line 1472 (in the force rescan/update flow)
|
|
|
|
Apply same `getRelativePath()` conversion when updating existing items.
|
|
|
|
---
|
|
|
|
## 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: Add URL Resolution Helper to MediaHandler
|
|
|
|
### Strategy
|
|
|
|
Use `LibraryService.ResolveMediaPath()` to resolve paths. Add a simple wrapper in the handler for convenience.
|
|
|
|
### File: `internal/handlers/media.go`
|
|
|
|
Add helper method that uses the service:
|
|
|
|
```go
|
|
// getFullFilePath returns the absolute filesystem path for a media item
|
|
// Uses LibraryService for resolution (one source of truth)
|
|
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
|
|
}
|
|
|
|
// Use service for resolution (one source of truth)
|
|
return mh.libraryService.ResolveMediaPath(ctx, libraryID, relativePath)
|
|
}
|
|
```
|
|
|
|
Note: The handler already has `libraryService` injected, so this just calls through to it.
|
|
|
|
---
|
|
|
|
## 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 Authenticated File Serving Handler
|
|
|
|
### File: `internal/handlers/media.go`
|
|
|
|
Create a single handler that serves both covers and book files:
|
|
|
|
```go
|
|
// ServeFile serves files (covers or books) via /uploads/library-{id}/path
|
|
// Requires JWT authentication
|
|
func (mh *MediaHandler) ServeFile(c echo.Context) error {
|
|
// URL format: /uploads/library-{libraryID}/{relativePath}
|
|
path := c.Param("*") // Gets everything after /uploads/library-{id}/
|
|
|
|
// Extract library ID from path
|
|
parts := strings.SplitN(path, "/", 2)
|
|
if len(parts) < 2 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid path"})
|
|
}
|
|
|
|
libraryIDStr := strings.TrimPrefix(parts[0], "library-")
|
|
libraryUUID, err := uuid.Parse(libraryIDStr)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library ID"})
|
|
}
|
|
|
|
relativePath := parts[1]
|
|
|
|
// Resolve using service
|
|
fullPath, err := mh.getFullFilePath(c.Request().Context(), pgtype.UUID{Bytes: libraryUUID, Valid: true}, relativePath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
|
}
|
|
|
|
// Check if file exists
|
|
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
|
}
|
|
|
|
// Determine content type
|
|
ext := strings.ToLower(filepath.Ext(fullPath))
|
|
contentType := "application/octet-stream"
|
|
if ext == ".jpg" || ext == ".jpeg" {
|
|
contentType = "image/jpeg"
|
|
} else if ext == ".png" {
|
|
contentType = "image/png"
|
|
} else if ext == ".webp" {
|
|
contentType = "image/webp"
|
|
} else if ext == ".epub" {
|
|
contentType = "application/epub+zip"
|
|
} else if ext == ".pdf" {
|
|
contentType = "application/pdf"
|
|
}
|
|
|
|
c.Response().Header().Set("Content-Type", contentType)
|
|
c.Response().Header().Set("Cache-Control", "public, max-age=86400")
|
|
return c.File(fullPath)
|
|
}
|
|
```
|
|
|
|
### File: `internal/router/media.go`
|
|
|
|
**Location**: After existing media routes
|
|
|
|
```go
|
|
// File serving - authenticated
|
|
// Note: Must be registered LAST as it's a wildcard route
|
|
protected.GET("/uploads/library-:id/*", cfg.MediaHandler.ServeFile)
|
|
```
|
|
|
|
**Important**: This route must be registered LAST because `/*` is a wildcard that matches everything.
|
|
|
|
---
|
|
|
|
## Phase 6: Update OPDS Handler for Device Support
|
|
|
|
### File: `internal/handlers/opds.go`
|
|
|
|
#### Modify GetCoverImage function
|
|
|
|
**Current code** (around line 477-549):
|
|
```go
|
|
func (h *OPDSHandler) GetCoverImage(c echo.Context) error {
|
|
// ...
|
|
coverPath := mediaItem.CoverImagePath.String
|
|
|
|
// Check if file exists
|
|
if _, err := os.Stat(coverPath); os.IsNotExist(err) {
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
// Open file
|
|
file, err := os.Open(coverPath)
|
|
// ...
|
|
}
|
|
```
|
|
|
|
**New code**:
|
|
```go
|
|
func (h *OPDSHandler) GetCoverImage(c echo.Context) error {
|
|
// ...
|
|
coverPath := mediaItem.CoverImagePath.String
|
|
|
|
// Resolve relative path using library service
|
|
fullPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, coverPath)
|
|
if err != nil {
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
// Check if file exists
|
|
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
// Open file
|
|
file, err := os.Open(fullPath)
|
|
// ...
|
|
}
|
|
```
|
|
|
|
**Note**: OPDSHandler already has `libraryService` injected, so it can use the same resolution logic.
|
|
|
|
**If OPDSHandler doesn't have libraryService**, add it:
|
|
|
|
```go
|
|
type OPDSHandler struct {
|
|
db *database.Queries
|
|
libraryService *services.LibraryService
|
|
conversionService interface {...}
|
|
}
|
|
|
|
func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryService, conversionService ...) *OPDSHandler {
|
|
return &OPDSHandler{
|
|
db: db,
|
|
libraryService: libraryService,
|
|
conversionService: conversionService,
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 7: Frontend - No Changes Needed (SSR)
|
|
|
|
The existing frontend code should work without modification:
|
|
|
|
- **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
|
|
|
|
**Note**: Backend resolves URLs when building API responses, so frontend just uses the URL directly - no extra requests.
|
|
|
|
---
|
|
|
|
## Phase 8: Backward Compatibility
|
|
|
|
Handle existing absolute paths in database:
|
|
|
|
### Option A: Migration (One-time)
|
|
Create a script to convert existing absolute paths to relative paths using known library folder paths.
|
|
|
|
### Option B: Runtime Resolution (No migration)
|
|
Add backward compatibility in handlers:
|
|
|
|
```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 8: Tests
|
|
|
|
### Unit Tests
|
|
|
|
#### File: `internal/handlers/media_test.go`
|
|
|
|
```go
|
|
// TestGetCoverImage_ValidItem tests successful cover image retrieval
|
|
func TestGetCoverImage_ValidItem(t *testing.T) {
|
|
// Setup test server with mock database
|
|
// Create a test cover image file
|
|
// Call GetCoverImage
|
|
// Verify response has correct Content-Type and status code
|
|
}
|
|
|
|
// TestGetCoverImage_NotFound tests 404 for non-existent media item
|
|
func TestGetCoverImage_NotFound(t *testing.T) {
|
|
// Call with invalid UUID
|
|
// Verify 404 response
|
|
}
|
|
|
|
// TestGetCoverImage_NoCover tests 404 when media item has no cover
|
|
func TestGetCoverImage_NoCover(t *testing.T) {
|
|
// Create media item with empty cover_image_path
|
|
// Verify 404 response
|
|
}
|
|
|
|
// TestGetFullFilePath_RelativePath tests relative path resolution
|
|
func TestGetFullFilePath_RelativePath(t *testing.T) {
|
|
// Setup: Create library with folder /app/uploads
|
|
// Media item with file_path: "Author/Book/book.epub"
|
|
// Call getFullFilePath
|
|
// Verify returns: "/app/uploads/Author/Book/book.epub"
|
|
}
|
|
|
|
// 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_file_serving_test.go`
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/stretchr/testify/suite"
|
|
)
|
|
```
|
|
|
|
Note: The integration tests use `setupTestServer(s.T())` from `cmd/server/tests/test_helpers.go` as per PROJECT_GUIDELINES.md requirements.
|
|
|
|
### Bruno API Tests
|
|
|
|
Create new Bruno test files for the new endpoints:
|
|
|
|
#### File: `bruno/media-items/Get Cover Image.yml`
|
|
|
|
```yaml
|
|
info:
|
|
name: Get Cover Image
|
|
type: http
|
|
seq: 1
|
|
http:
|
|
method: GET
|
|
url: '{{base_url}}/api/covers/{{media_item_id}}'
|
|
auth: none
|
|
|
|
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
|
|
|
|
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 9: Documentation
|
|
|
|
### File: `docs/developer/api/media-items/get_cover_image.md`
|
|
|
|
```markdown
|
|
---
|
|
title: Get Cover Image
|
|
description: Retrieve the cover image for a media item
|
|
---
|
|
|
|
# Get Cover Image
|
|
|
|
Retrieve the cover image for a media item.
|
|
|
|
## Endpoint
|
|
|
|
`GET /api/covers/:id`
|
|
|
|
## Path Parameters
|
|
|
|
| Parameter | Type | Description |
|
|
|-----------|------|-------------|
|
|
| id | string | The media item ID (UUID) |
|
|
|
|
## Headers
|
|
|
|
| Header | Required | Description |
|
|
|--------|----------|-------------|
|
|
| Authorization | Yes | Bearer token |
|
|
|
|
## Response
|
|
|
|
- **200 OK**: Cover image returned
|
|
- Content-Type: `image/jpeg`, `image/png`, etc.
|
|
- Cache-Control: `public, max-age=86400`
|
|
|
|
- **400 Bad Request**: Invalid media item ID
|
|
|
|
- **404 Not Found**:
|
|
- Media item not found
|
|
- No cover image configured
|
|
- Cover image file not found on disk
|
|
|
|
## Example
|
|
|
|
```bash
|
|
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
|
http://localhost:8765/api/covers/550e8400-e29b-41d4-a716-446655440000 \
|
|
--output cover.jpg
|
|
```
|
|
|
|
## Notes
|
|
|
|
- Cover images are stored relative to their library folder
|
|
- The API resolves the full path using the library's configured folder(s)
|
|
- Supports backward compatibility with existing absolute paths
|
|
- Images are cached for 24 hours by clients
|
|
- 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 `getRelativePath()` function; use for both file_path and cover_path |
|
|
| 2 | `internal/services/library_service.go` | Add `ResolveMediaPath()` function (one source of truth) |
|
|
| 3 | `internal/handlers/media.go` | Add `getFullFilePath()` helper that calls service |
|
|
| 4 | `internal/handlers/media.go` | Modify `DownloadBook` to use `getFullFilePath()` |
|
|
| 5 | `internal/handlers/media.go` | Add `ServeFile()` handler for authenticated static-style routes |
|
|
| 5 | `internal/router/media.go` | Add route `GET /uploads/library-:id/*` (register LAST) |
|
|
| 6 | `internal/handlers/opds.go` | Add `libraryService` to struct; update `GetCoverImage` to use service |
|
|
| 7 | Frontend files | No changes needed (backend resolves URLs) |
|
|
| 8 | Runtime resolution | Handles both absolute (old) and relative (new) paths |
|
|
| 9 | `internal/handlers/media_test.go` | Add unit tests for path resolution |
|
|
| 9 | `internal/services/media_scanner_test.go` | Add unit tests for `getRelativePath()` |
|
|
| 9 | `cmd/server/tests/cover_file_serving_test.go` | Add integration tests using test_helpers |
|
|
| 10 | `bruno/media-items/Get Cover Image.yml` | Add Bruno API test |
|
|
| 10 | `bruno/media-items/EPUB Download.yml` | Update to document relative path handling |
|
|
| 10 | `docs/developer/api/media-items/` | Update API documentation |
|
|
|
|
---
|
|
|
|
## Verification Steps
|
|
|
|
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 `/uploads/library-{id}/Author/Book/cover.jpg` returns the image
|
|
- GET `/api/media-items/:id/download` returns the file
|
|
|
|
2. **Test existing data**: For items with absolute paths:
|
|
- Downloads still work (backward compatibility)
|
|
- Cover images still work (backward compatibility)
|
|
|
|
3. **Test multiple mount points**:
|
|
- Library A with folder `/app/epubs`
|
|
- Library B with folder `/var/manga`
|
|
- Books in each resolve correctly via their library ID
|
|
|
|
4. **Test frontend**:
|
|
- Dashboard shows cover images (SSR - initial load)
|
|
- Library switch works (dynamic - uses resolved URLs)
|
|
- Bookshelf shows cover images
|
|
- Downloads work
|
|
|
|
5. **Test mobile app** (future):
|
|
- Same JWT auth works for files and covers
|
|
- `/uploads/library-{id}/...` URLs work
|
|
|
|
6. **Test device integration**:
|
|
- Kobo devices can fetch cover images via OPDS
|
|
- KOReader sync continues to work
|
|
|
|
---
|
|
|
|
## 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.
|