Update Bruno collection test documentation to reflect the new unified static-style
URL strategy for cover images and media downloads.
Changes:
- Update cover image endpoint from /api/covers/{id} to /uploads/library-{id}/{path}
- Update download endpoint from /api/media-items/{id}/download to /uploads/library-{id}/{path}
- Document JWT authentication for static endpoints (same as API endpoints)
- Add clarification that resolved URLs come from API responses
- Update status codes to reflect new endpoint behavior
- Rename 'Download Media Item.yml' to 'EPUB Download.yml' for clarity
This documentation aligns with the unified URL strategy where all file access
goes through a consistent /uploads/library-{id}/ pattern with JWT-based
authentication, eliminating separate API endpoints for file serving.
22 KiB
Cover & File Serving - Implementation Plan
Overview
Fix file and cover image serving to support:
- Multiple library folders in docker compose (flexible mount points)
- Keep files with books (no hardcoded paths)
- Store relative paths in database (for both files AND covers)
- Serve everything via authenticated API endpoints
- 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:
metadata.FilePath = path // path is absolute like /app/uploads/Author/Book/file.epub
New code:
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):
if len(coverImage) > 0 && metadata.CoverPath == "" {
coverPath := path + ".cover.jpg"
if err := os.WriteFile(coverPath, coverImage, 0644); err == nil {
metadata.CoverPath = coverPath
}
}
New code:
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:
// 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:
// 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:
// 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):
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:
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:
// 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
// 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):
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:
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:
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.CoverImagePathdirectly in SSR - dashboard.ts: Uses
book.cover_image_pathfrom API response - bookshelf.ts: Uses
book.cover_image_pathfrom 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:
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
// 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
// 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
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
info:
name: Get Cover Image
type: http
seq: 1
http:
method: GET
url: '{{base_url}}/uploads/library-{{library_id}}/path/to/cover.jpg'
auth: none
docs: |-
## Get Cover Image
Retrieve the cover image for a media item via authenticated static-style URL.
Uses JWT authentication (same as API endpoints).
**Method:** GET
**Endpoint:** /uploads/library-{id}/{path}
**Authentication:** Bearer token required
**Response:** Binary image data (JPEG, PNG, etc.)
**Status Codes:**
- 200: Success - returns image
- 401: Unauthorized (missing/invalid JWT)
- 404: File not found
**Note:** The actual path would come from the API response which provides
the resolved URL. This test is a template showing the URL format.
vars:
library_id: ""
File: bruno/media-items/EPUB Download.yml (Update existing)
Update the existing file to note that downloads now work through the same /uploads/library-{id}/ endpoint:
info:
name: Download Media Item
type: http
seq: 1
http:
method: GET
url: '{{base_url}}/uploads/library-{{library_id}}/path/to/book.epub'
auth: none
docs: |-
## Download Media Item
Download a media item file (EPUB, PDF, CBZ, etc.) via authenticated static-style URL.
Uses JWT authentication (same as API endpoints).
**Method:** GET
**Endpoint:** /uploads/library-{id}/{path}
**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
- 401: Unauthorized (missing/invalid JWT)
- 404: File not found
**Note:** The actual path would come from the API response which provides
the resolved URL. This test shows the URL format.
vars:
library_id: ""
Phase 9: Documentation
File: docs/developer/api/media-items/get_cover_image.md
---
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.