This commit updates all documentation files throughout the project: - Updated IMPLEMENTATION_PLAN.md with new implementation details - Updated PROJECT_GUIDELINES.md with coding standards and practices - Updated README.md with current project information - Updated SCREENSHOT_AUTOMATION.md with new automation details - Added TEST_DATA.md with test fixtures data - Updated cover_image_serving_plan.md with static URL patterns Documentation API updates: - Updated API reference documentation for all endpoints including: - Authentication (login, logout, register, refresh_token) - Book matching (auto_link, bulk_link, link_book, search) - Collections (CRUD operations, shelf mappings, auto-assign rules) - Conflicts (bulk operations, resolve/dismiss) - Devices (registration, approval, shelf management) - Highlights (create, update, delete, get) - Kobo sync (bookmark, markup, initialization, sync) - KOReader sync (library, metadata, bookmarks, progress) - Libraries (CRUD, folders, media items, stats) - Media items (bulk operations, CRUD) - Notes (CRUD operations) - OPDS (acquisition, feeds, publication) - Progress (reading progress tracking) - Queue (device queue management) - Ratings (star ratings) - Scanner (watch mode, scan operations) - Sync protocols (Kobo, KOReader) - Users (profile, password, admin operations) - WebSocket protocols - Updated user guides (admin, dashboard, settings, sync) - Updated device setup guides (Kobo, KOReader) - Updated developer guides (testing, contributing, operations) - Updated scripts/README.md
38 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: In internal/services/media_scanner.go - wherever FilePath is set in the database insert
Current code (line 579):
FilePath: path, // path is absolute like /app/uploads/Author/Book/file.epub
New code:
FilePath: s.getRelativePath(path),
Also update line 617 for format file paths:
FilePath: pgtype.Text{String: s.getRelativePath(format.FilePath), Valid: true},
Change 2: Store relative cover path
Location: In internal/services/media_scanner.go - wherever metadata.CoverPath is set
Current code (example at line 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)
}
}
All locations where metadata.CoverPath is set:
- Line 517 (main cover)
- Line 645 (sidecar cover)
- Line 651 (sidecar cover alternative)
- Line 1060 (main cover)
- Line 1067 (sidecar cover)
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 (NOT in the protected group)
// File serving - authenticated (registered on Echo to avoid /api prefix)
// Note: Must be registered LAST as it's a wildcard route
e.GET("/uploads/library-:id/*", createJWTMiddleware(cfg), 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 uses ResolveMediaPath() to resolve to filesystem path (not URL) because OPDS serves files directly from the local filesystem. This is different from API handlers which resolve to /uploads/library-{id}/... URLs.
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: Update ALL Handlers to Resolve URLs in API Responses
Every handler that returns cover_image_path or file_path in API responses must resolve the relative path to a full URL before sending to the client.
URL Resolution Flow
- Database stores: Relative path (e.g.,
Author/Book/cover.jpg) - Handler fetches: Gets relative path from DB (as pgtype.Text)
- Handler resolves: Calls
ResolveCoverURL()→ returns/uploads/library-{id}/Author/Book/cover.jpg - Handler replaces: Sets resolved URL string in response (not pgtype.Text)
- Frontend/mobile: Uses URL directly (authenticated via JWT)
Step 1: Add URL Resolution Helper to MediaHandler
File: internal/handlers/media.go
Add these helper methods after the NewMediaHandler function (around line 100):
// ResolveCoverURL resolves a relative cover path to a full URL for API responses
func (mh *MediaHandler) ResolveCoverURL(libraryID pgtype.UUID, coverPath pgtype.Text) string {
if !coverPath.Valid || coverPath.String == "" {
return ""
}
return mh.resolveMediaURL(libraryID, coverPath.String)
}
// ResolveFileURL resolves a relative file path to a full URL for API responses
func (mh *MediaHandler) ResolveFileURL(libraryID pgtype.UUID, filePath pgtype.Text) string {
if !filePath.Valid || filePath.String == "" {
return ""
}
return mh.resolveMediaURL(libraryID, filePath.String)
}
// resolveMediaURL is the internal helper that does the actual resolution
func (mh *MediaHandler) resolveMediaURL(libraryID pgtype.UUID, relativePath string) string {
// Already a full URL? Return as-is
if strings.HasPrefix(relativePath, "/uploads/") {
return relativePath
}
// Already absolute path? Convert to URL format (backward compatibility)
// Note: This loses library ID info, but existing data won't have it
if filepath.IsAbs(relativePath) {
return relativePath
}
// Resolve relative path to URL format
libraryIDStr := libraryID.Bytes.String()
return fmt.Sprintf("/uploads/library-%s/%s", libraryIDStr, relativePath)
}
Note: Add "strings" and "fmt" to the imports if not already present.
Step 1b: Add libraryService to CollectionHandler
File: internal/handlers/collections.go
Add libraryService field to CollectionHandler struct (near line 10):
type CollectionHandler struct {
db *database.Queries
collectionService *services.CollectionService
libraryService *services.LibraryService // ADD THIS
connManager *wsync.ConnectionManager
}
Update constructor to accept and set libraryService:
func NewCollectionHandler(db *database.Queries, libraryService *services.LibraryService, connManager *wsync.ConnectionManager) *CollectionHandler {
return &CollectionHandler{
db: db,
collectionService: services.NewCollectionService(db),
libraryService: libraryService, // ADD THIS
connManager: connManager,
}
}
Update router where CollectionHandler is instantiated (likely in router/collections.go or similar):
cfg.CollectionHandler, err = handlers.NewCollectionHandler(cfg.Queries, cfg.LibraryService, cfg.ConnManager)
Step 2: Update collections.go - GetCollection handler (line ~193-201)
File: internal/handlers/collections.go
Current code (lines 193-201 in GetCollection function):
bookList := make([]BookInfo, 0, len(books))
for _, book := range books {
bookList = append(bookList, BookInfo{
MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(),
Title: book.Title,
Author: textToString(book.Author),
CoverImagePath: textToString(book.CoverImagePath),
})
}
New code:
bookList := make([]BookInfo, 0, len(books))
for _, book := range books {
bookList = append(bookList, BookInfo{
MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(),
Title: book.Title,
Author: textToString(book.Author),
CoverImagePath: h.resolveCoverURL(book.LibraryID, book.CoverImagePath),
})
}
Add helper method to CollectionHandler struct (near line 10):
// resolveCoverURL resolves a relative cover path to a full URL
func (h *CollectionHandler) resolveCoverURL(libraryID pgtype.UUID, coverPath pgtype.Text) string {
if !coverPath.Valid || coverPath.String == "" {
return ""
}
// Already a full URL? Return as-is
if strings.HasPrefix(coverPath.String, "/uploads/") {
return coverPath.String
}
// Already absolute path? Return as-is (backward compatibility)
if filepath.IsAbs(coverPath.String) {
return coverPath.String
}
// Resolve relative path to URL format
libraryIDStr := libraryID.Bytes.String()
return fmt.Sprintf("/uploads/library-%s/%s", libraryIDStr, coverPath.String)
}
Add imports if not present: "fmt", "path/filepath", "strings"
Step 3: Update collections.go - TestRules/BookMatch (lines 620-641)
File: internal/handlers/collections.go
Current code (lines 620-641 in TestRules function):
var matches []BookMatch
for _, item := range mediaItems {
matchReason := h.checkRulesAgainstBook(item, req.Rules)
if matchReason != "" {
coverPath := ""
if item.CoverImagePath.Valid {
coverPath = item.CoverImagePath.String
}
author := ""
if item.Author.Valid {
author = item.Author.String
}
matches = append(matches, BookMatch{
MediaItemID: uuid.UUID(item.ID.Bytes).String(),
Title: item.Title,
Author: author,
CoverImagePath: coverPath,
MatchReason: matchReason,
})
}
}
New code:
var matches []BookMatch
for _, item := range mediaItems {
matchReason := h.checkRulesAgainstBook(item, req.Rules)
if matchReason != "" {
author := ""
if item.Author.Valid {
author = item.Author.String
}
matches = append(matches, BookMatch{
MediaItemID: uuid.UUID(item.ID.Bytes).String(),
Title: item.Title,
Author: author,
CoverImagePath: h.resolveCoverURL(item.LibraryID, item.CoverImagePath),
MatchReason: matchReason,
})
}
}
Step 4: Update collections.go - PreviewCollection (lines 910-919) and mediaItemsToListMediaItemsRow helper (line 935)
File: internal/handlers/collections.go
Location 1 - PreviewCollection function (lines 910-919):
Current code:
bookCards := make([]BookInfo, len(matchedItems))
for i, item := range matchedItems {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards[i] = BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: textToString(item.CoverImagePath),
}
}
New code:
bookCards := make([]BookInfo, len(matchedItems))
for i, item := range matchedItems {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards[i] = BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: h.resolveCoverURL(item.LibraryID, item.CoverImagePath),
}
}
Location 2 - mediaItemsToListMediaItemsRow helper (line 935):
Current code:
func mediaItemsToListMediaItemsRow(item database.MediaItems) database.ListMediaItemsRow {
return database.ListMediaItemsRow{
// ...
CoverImagePath: item.CoverImagePath,
// ...
}
}
New code:
// NOTE: This helper function doesn't have access to libraryID
// Consider refactoring to pass libraryID or handle URL resolution at call site
Add helper method for file URL resolution:
// resolveFileURL resolves a relative file path to a full URL
func (h *CollectionHandler) resolveFileURL(libraryID pgtype.UUID, filePath pgtype.Text) string {
if !filePath.Valid || filePath.String == "" {
return ""
}
if strings.HasPrefix(filePath.String, "/uploads/") {
return filePath.String
}
if filepath.IsAbs(filePath.String) {
return filePath.String
}
libraryIDStr := libraryID.Bytes.String()
return fmt.Sprintf("/uploads/library-%s/%s", libraryIDStr, filePath.String)
}
Step 5: Update progress.go - two locations (lines 286-289 and 357-360)
File: internal/handlers/progress.go
First, add helper methods to Handler struct (defined in commonhandlers.go, used by progress.go):
// resolveCoverURL resolves a relative cover path to a full URL
func (h *Handler) resolveCoverURL(libraryID pgtype.UUID, coverPath pgtype.Text) string {
if !coverPath.Valid || coverPath.String == "" {
return ""
}
if strings.HasPrefix(coverPath.String, "/uploads/") {
return coverPath.String
}
if filepath.IsAbs(coverPath.String) {
return coverPath.String
}
libraryIDStr := libraryID.Bytes.String()
return fmt.Sprintf("/uploads/library-%s/%s", libraryIDStr, coverPath.String)
}
Location 1 - GetAllProgress function (lines 286-289):
Current code:
coverPath := ""
if mediaItem.CoverImagePath.Valid {
coverPath = mediaItem.CoverImagePath.String
}
New code (remove the manual resolution, use helper):
coverPath := h.resolveCoverURL(mediaItem.LibraryID, mediaItem.CoverImagePath)
Location 2 - GetAllProgressData function (lines 357-360):
Current code:
coverPath := ""
if mediaItem.CoverImagePath.Valid {
coverPath = mediaItem.CoverImagePath.String
}
New code:
coverPath := h.resolveCoverURL(mediaItem.LibraryID, mediaItem.CoverImagePath)
Step 6: Update media.go - GetMediaItem and ListMediaItems
File: internal/handlers/media.go
Add to imports:
"bookhoard/internal/utils"
GetMediaItem - Find where it returns the response (around line 770):
Current code:
return c.JSON(http.StatusOK, item)
New code:
return c.JSON(http.StatusOK, map[string]interface{}{
"id": uuid.UUID(item.ID.Bytes).String(),
"library_id": uuid.UUID(item.LibraryID.Bytes).String(),
"title": item.Title,
"author": textToString(item.Author),
"cover_image_path": utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
"file_path": utils.ResolveMediaURL(item.LibraryID, item.FilePath),
"file_size": item.FileSize,
"mime_type": textToString(item.MimeType),
// ... add other fields as needed
})
ListMediaItems - Find where it returns items (around line 609):
Wrap each item in the response with resolved URLs. The exact implementation depends on how ListMediaItems currently returns data - you may need to build a custom response map similar to GetMediaItem.
Note: Unlike collections.go and progress.go where we added helper methods to the handler, here we use the utils package function directly since we've consolidated URL resolution into utils.
Step 7: Fix Frontend /covers/ Prefix
File: web/src/bookshelf.ts
Current code (line 49-50):
${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 code:
${book.cover_image_path ?
`<img src="${book.cover_image_path}" alt="${book.title}" class="w-full h-48 object-cover rounded mb-2">` :
The backend now returns full URLs like /uploads/library-{id}/path/to/cover.jpg, so no prefix is needed.
Summary of Changes for Phase 7
| File | Changes |
|---|---|
internal/utils/mediaurl.go |
Create with ResolveMediaURL() function for URL resolution (one source of truth) |
internal/handlers/media.go |
Update GetMediaItem and ListMediaItems to use utils.ResolveMediaURL() for resolved URLs in responses |
internal/handlers/collections.go |
Use utils.ResolveMediaURL() in GetCollection, TestRules, PreviewCollection; update lines 193-201, 620-641, 910-919 |
internal/handlers/progress.go |
Use utils.ResolveMediaURL() in GetAllProgress; update lines 286-289, 357-360 |
web/src/bookshelf.ts |
Remove /covers/ prefix from cover image URL |
Additional Plan Updates Needed
| Item | Status |
|---|---|
Add mi.library_id to GetCollectionItems SQL query |
Needs to be done before implementing Step 2 in collections.go |
Create internal/utils/mediaurl.go |
Needs to be created before implementing URL resolution |
| Update callers to use utils package | Replace h.resolveCoverURL/resolveFileURL with utils.ResolveMediaURL |
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 9: 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 |
|---|---|---|
| Refactor | internal/handlers/commonhandlers.go |
Move Handler struct from scanner.go (kitchen sink handler for progress/book matching) |
| Refactor | internal/routers/*.go |
Update NewHandler instantiation if needed |
| 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/* on Echo (not protected group) |
| 6 | internal/handlers/opds.go |
Add libraryService to struct; update GetCoverImage to use service |
| 7 | internal/handlers/collections.go |
Add libraryService to struct/constructor; resolve cover paths to URLs in API responses |
| 7 | internal/router/*.go |
Update CollectionHandler instantiation to pass LibraryService |
| 7 | internal/handlers/commonhandlers.go (Handler struct, used by progress.go) |
Resolve cover paths to URLs in API responses |
| 7 | internal/handlers/media.go |
Resolve file paths to URLs in API responses |
| 7 | web/src/bookshelf.ts |
Remove /covers/ prefix (use resolved URL directly) |
| 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:
-
Test new scan: Add a new book with cover, verify:
- Database
file_pathis relative (e.g.,Author/Book/book.epub) - Database
cover_image_pathis relative (e.g.,Author/Book/cover.jpg) - GET
/uploads/library-{id}/Author/Book/cover.jpgreturns the image - GET
/api/media-items/:id/downloadreturns the file
- Database
-
Test existing data: For items with absolute paths:
- Downloads still work (backward compatibility)
- Cover images still work (backward compatibility)
-
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
- Library A with folder
-
Test frontend:
- Dashboard shows cover images (SSR - initial load)
- Library switch works (dynamic - uses resolved URLs)
- Bookshelf shows cover images
- Downloads work
-
Test mobile app (future):
- Same JWT auth works for files and covers
/uploads/library-{id}/...URLs work
-
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:
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.