feat: implement relative path storage and URL resolution for media files

- Add libraryService dependency to CollectionHandler and OPDSHandler for centralized path resolution
- Create internal/utils/mediaurl.go with ResolveMediaURL() function as single source of truth
- Update GetMediaItem and ListMediaItems handlers to return resolved URLs in API responses
- Update collection handlers (GetCollection, TestRules, PreviewCollection) to use resolved cover URLs
- Update progress handler (GetAllProgress) to use resolved cover URLs
- Add library_id to GetCollectionItems SQL query to enable URL resolution
- Refactor media scanner to store relative paths instead of absolute filesystem paths
- Add ResolveMediaPath() to LibraryService for resolving relative paths to absolute paths
- Add ServeFile endpoint at /uploads/library-:id/* for authenticated file serving
- Add MimeTypes map to library_service.go for consistent MIME type handling
- Update DownloadBook handler to use resolved filesystem paths
- Add getRelativePath() helper to MediaScanner for converting absolute to relative paths
- Use strings.EqualFold for case-insensitive path comparisons in zip extraction

This change enables the application to work with relative paths stored in the
database, making it portable across different server environments while
maintaining backward compatibility with existing absolute paths.
This commit is contained in:
2026-02-27 16:51:44 -05:00
parent 123ab0c966
commit 209e9f2a3c
13 changed files with 300 additions and 99 deletions
+2 -2
View File
@@ -115,10 +115,10 @@ func main() {
// Create conversion service for EPUB→KEPUB conversion // Create conversion service for EPUB→KEPUB conversion
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub") conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
opdsHandler := handlers.NewOPDSHandler(queries, conversionService) opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
// NEW: Create refactored handlers // NEW: Create refactored handlers
collectionHandler := handlers.NewCollectionHandler(queries, connManager) collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
dashboardService := services.NewDashboardService(queries) dashboardService := services.NewDashboardService(queries)
dashboardHandler := handlers.NewDashboardHandler(queries) dashboardHandler := handlers.NewDashboardHandler(queries)
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker) mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
+2 -2
View File
@@ -459,7 +459,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
// Create refactored handlers (matching main.go) // Create refactored handlers (matching main.go)
libraryService := services.NewLibraryService(queries) libraryService := services.NewLibraryService(queries)
worker := services.NewWorker(3) worker := services.NewWorker(3)
collectionHandler := handlers.NewCollectionHandler(queries, connManager) collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
dashboardService := services.NewDashboardService(queries) dashboardService := services.NewDashboardService(queries)
dashboardHandler := handlers.NewDashboardHandler(queries) dashboardHandler := handlers.NewDashboardHandler(queries)
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker) mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
@@ -467,7 +467,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
// Create conversion service for OPDS // Create conversion service for OPDS
conversionService := services.NewConversionService(queries, getCachePath()) conversionService := services.NewConversionService(queries, getCachePath())
opdsHandler := handlers.NewOPDSHandler(queries, conversionService) opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
// Create Echo instance // Create Echo instance
e := echo.New() e := echo.New()
+24 -27
View File
@@ -741,29 +741,12 @@ coverPath := h.resolveCoverURL(mediaItem.LibraryID, mediaItem.CoverImagePath)
**File**: `internal/handlers/media.go` **File**: `internal/handlers/media.go`
The current implementation returns raw database rows directly. We need to convert them to API-safe responses with resolved URLs. Add to imports:
**Option A: Quick fix** - Modify the response before returning (lines 609, 620, 639)
For `ListMediaItems` (around line 609 and 620), add a helper to convert each item:
```go ```go
// Add this function somewhere in media.go "bookhoard/internal/utils"
func resolveMediaItemCoverAndFile(item database.ListMediaItemsRow) database.ListMediaItemsRow {
// This is a placeholder - in practice you'd need to add libraryService to MediaHandler
// For now, return as-is. Full implementation requires adding libraryService dependency.
return item
}
``` ```
**Note**: The `ListMediaItems` and `GetMediaItem` functions currently return raw database rows. To properly resolve URLs, you would need to either: **GetMediaItem** - Find where it returns the response (around line 770):
1. **Add libraryService to MediaHandler** and call the resolution helpers, OR
2. **Create a separate response struct** that converts pgtype.Text to resolved URLs
For this implementation, the recommended approach is:
**Modify GetMediaItem** (line 639):
**Current code**: **Current code**:
```go ```go
@@ -777,13 +760,19 @@ return c.JSON(http.StatusOK, map[string]interface{}{
"library_id": uuid.UUID(item.LibraryID.Bytes).String(), "library_id": uuid.UUID(item.LibraryID.Bytes).String(),
"title": item.Title, "title": item.Title,
"author": textToString(item.Author), "author": textToString(item.Author),
"cover_image_path": mh.ResolveCoverURL(item.LibraryID, item.CoverImagePath), "cover_image_path": utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
"file_path": mh.ResolveFileURL(item.LibraryID, item.FilePath), "file_path": utils.ResolveMediaURL(item.LibraryID, item.FilePath),
"file_size": item.FileSize,
"mime_type": textToString(item.MimeType),
// ... add other fields as needed // ... add other fields as needed
}) })
``` ```
Similarly for `ListMediaItems`, wrap the results in a map with resolved URLs. **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.
--- ---
@@ -811,14 +800,22 @@ The backend now returns full URLs like `/uploads/library-{id}/path/to/cover.jpg`
| File | Changes | | File | Changes |
|------|---------| |------|---------|
| `internal/handlers/media.go` | Add `ResolveCoverURL()`, `ResolveFileURL()`, `resolveMediaURL()` helpers | | `internal/utils/mediaurl.go` | Create with `ResolveMediaURL()` function for URL resolution (one source of truth) |
| `internal/handlers/collections.go` | Add `libraryService` to struct and constructor; add `resolveCoverURL()`, `resolveFileURL()` helpers; update lines 193-201, 620-641, 910-919 | | `internal/handlers/media.go` | Update GetMediaItem and ListMediaItems to use `utils.ResolveMediaURL()` for resolved URLs in responses |
| `internal/handlers/progress.go` | Add `resolveCoverURL()` to Handler (in commonhandlers.go); update lines 286-289, 357-360 | | `internal/handlers/collections.go` | Use `utils.ResolveMediaURL()` in GetCollection, TestRules, PreviewCollection; update lines 193-201, 620-641, 910-919 |
| `internal/handlers/media.go` | Update `GetMediaItem` to return resolved URLs in response map | | `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 | | `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 ## Phase 8: Backward Compatibility
Handle existing absolute paths in database: Handle existing absolute paths in database:
+3 -1
View File
@@ -1639,7 +1639,7 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection
} }
const GetCollectionItems = `-- name: GetCollectionItems :many const GetCollectionItems = `-- name: GetCollectionItems :many
SELECT ci.id, ci.collection_id, ci.media_item_id, ci.added_at, ci.added_by_user_id, ci.excluded, mi.title, mi.author, mi.cover_image_path SELECT ci.id, ci.collection_id, ci.media_item_id, ci.added_at, ci.added_by_user_id, ci.excluded, mi.title, mi.author, mi.cover_image_path, mi.library_id
FROM collection_items ci FROM collection_items ci
JOIN media_items mi ON ci.media_item_id = mi.id JOIN media_items mi ON ci.media_item_id = mi.id
WHERE ci.collection_id = $1 WHERE ci.collection_id = $1
@@ -1656,6 +1656,7 @@ type GetCollectionItemsRow struct {
Title string `db:"title" json:"title"` Title string `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"` Author pgtype.Text `db:"author" json:"author"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"` CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
} }
// Get collection items // Get collection items
@@ -1678,6 +1679,7 @@ func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UU
&i.Title, &i.Title,
&i.Author, &i.Author,
&i.CoverImagePath, &i.CoverImagePath,
&i.LibraryID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
+1 -1
View File
@@ -1363,7 +1363,7 @@ DELETE FROM collection_items WHERE collection_id = $1 AND media_item_id = $2;
-- Get collection items -- Get collection items
-- name: GetCollectionItems :many -- name: GetCollectionItems :many
SELECT ci.*, mi.title, mi.author, mi.cover_image_path SELECT ci.*, mi.title, mi.author, mi.cover_image_path, mi.library_id
FROM collection_items ci FROM collection_items ci
JOIN media_items mi ON ci.media_item_id = mi.id JOIN media_items mi ON ci.media_item_id = mi.id
WHERE ci.collection_id = $1 WHERE ci.collection_id = $1
+7 -8
View File
@@ -4,6 +4,7 @@ import (
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/services" "bookhoard/internal/services"
wsync "bookhoard/internal/sync" wsync "bookhoard/internal/sync"
"bookhoard/internal/utils"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -20,13 +21,15 @@ import (
type CollectionHandler struct { type CollectionHandler struct {
db *database.Queries db *database.Queries
collectionService *services.CollectionService collectionService *services.CollectionService
libraryService *services.LibraryService
connManager *wsync.ConnectionManager connManager *wsync.ConnectionManager
} }
func NewCollectionHandler(db *database.Queries, connManager *wsync.ConnectionManager) *CollectionHandler { func NewCollectionHandler(db *database.Queries, libraryService *services.LibraryService, connManager *wsync.ConnectionManager) *CollectionHandler {
return &CollectionHandler{ return &CollectionHandler{
db: db, db: db,
collectionService: services.NewCollectionService(db), collectionService: services.NewCollectionService(db),
libraryService: libraryService,
connManager: connManager, connManager: connManager,
} }
} }
@@ -196,7 +199,7 @@ func (h *CollectionHandler) GetCollection(c echo.Context) error {
MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(), MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(),
Title: book.Title, Title: book.Title,
Author: textToString(book.Author), Author: textToString(book.Author),
CoverImagePath: textToString(book.CoverImagePath), CoverImagePath: utils.ResolveMediaURL(book.LibraryID, book.CoverImagePath),
}) })
} }
@@ -621,10 +624,6 @@ func (h *CollectionHandler) TestRules(c echo.Context) error {
for _, item := range mediaItems { for _, item := range mediaItems {
matchReason := h.checkRulesAgainstBook(item, req.Rules) matchReason := h.checkRulesAgainstBook(item, req.Rules)
if matchReason != "" { if matchReason != "" {
coverPath := ""
if item.CoverImagePath.Valid {
coverPath = item.CoverImagePath.String
}
author := "" author := ""
if item.Author.Valid { if item.Author.Valid {
author = item.Author.String author = item.Author.String
@@ -634,7 +633,7 @@ func (h *CollectionHandler) TestRules(c echo.Context) error {
MediaItemID: uuid.UUID(item.ID.Bytes).String(), MediaItemID: uuid.UUID(item.ID.Bytes).String(),
Title: item.Title, Title: item.Title,
Author: author, Author: author,
CoverImagePath: coverPath, CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
MatchReason: matchReason, MatchReason: matchReason,
}) })
} }
@@ -914,7 +913,7 @@ func (h *CollectionHandler) PreviewCollection(c echo.Context) error {
MediaItemID: itemUUID.String(), MediaItemID: itemUUID.String(),
Title: item.Title, Title: item.Title,
Author: textToString(item.Author), Author: textToString(item.Author),
CoverImagePath: textToString(item.CoverImagePath), CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
} }
} }
+126 -4
View File
@@ -4,12 +4,15 @@ import (
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/services" "bookhoard/internal/services"
"bookhoard/internal/utils" "bookhoard/internal/utils"
"context"
"fmt"
"io" "io"
"mime" "mime"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -113,11 +116,17 @@ func (h *MediaHandler) DownloadBook(c echo.Context) error {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"}) return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
} }
if _, err := os.Stat(mediaItem.FilePath); os.IsNotExist(err) { // 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"}) return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found on disk"})
} }
file, err := os.Open(mediaItem.FilePath) 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)
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to open book file"}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to open book file"})
} }
@@ -617,7 +626,36 @@ func (mh *MediaHandler) ListMediaItems(c echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
return c.JSON(http.StatusOK, map[string]interface{}{"data": items}) resolvedItems := make([]map[string]interface{}, len(items))
for i, item := range items {
resolvedItems[i] = 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),
"isbn": textToString(item.Isbn),
"description": textToString(item.Description),
"file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}),
"file_size": item.FileSize,
"mime_type": textToString(item.MimeType),
"cover_image_path": utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
"series": textToString(item.Series),
"series_number": item.SeriesNumber,
"tags": item.Tags,
"asin": textToString(item.Asin),
"date_published": item.DatePublished.Time.Format("2006-01-02"),
"publisher": textToString(item.Publisher),
"contributors": item.Contributors,
"language": textToString(item.Language),
"edition": textToString(item.Edition),
"page_count": item.PageCount,
"genre": textToString(item.Genre),
"created_at": item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
"updated_at": item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
"format_group": item.FormatGroup, // already string
}
}
return c.JSON(http.StatusOK, map[string]interface{}{"data": resolvedItems})
} }
// GetMediaItem handles GET /api/media-items/:id // GetMediaItem handles GET /api/media-items/:id
@@ -636,7 +674,33 @@ func (mh *MediaHandler) GetMediaItem(c echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
return c.JSON(http.StatusOK, item) 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),
"isbn": textToString(item.Isbn),
"description": textToString(item.Description),
"file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}),
"file_size": item.FileSize,
"mime_type": textToString(item.MimeType),
"cover_image_path": utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
"series": textToString(item.Series),
"series_number": item.SeriesNumber,
"tags": item.Tags,
"asin": textToString(item.Asin),
"date_published": item.DatePublished.Time.Format("2006-01-02"),
"publisher": textToString(item.Publisher),
"contributors": item.Contributors,
"language": textToString(item.Language),
"edition": textToString(item.Edition),
"page_count": item.PageCount,
"genre": textToString(item.Genre),
"created_at": item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
"updated_at": item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
"format_group": item.FormatGroup,
"format_mimetype": textToString(item.FormatMimetype),
})
} }
// ListMediaItemsFiltered handles GET /api/media-items/filtered // ListMediaItemsFiltered handles GET /api/media-items/filtered
@@ -1407,3 +1471,61 @@ func (mh *MediaHandler) SearchMediaItems(c echo.Context) error {
return c.JSON(http.StatusOK, fuzzyResults) return c.JSON(http.StatusOK, fuzzyResults)
} }
// 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)
}
// 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"})
}
ext := strings.ToLower(filepath.Ext(fullPath))
contentType := services.MimeTypes[ext]
if contentType == "" {
contentType = "application/octet-stream"
}
c.Response().Header().Set("Content-Type", contentType)
c.Response().Header().Set("Cache-Control", "public, max-age=86400")
return c.File(fullPath)
}
+12 -4
View File
@@ -22,16 +22,18 @@ import (
type OPDSHandler struct { type OPDSHandler struct {
db *database.Queries db *database.Queries
libraryService *services.LibraryService
conversionService interface { conversionService interface {
ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*services.ConvertedKEPUB, error) ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*services.ConvertedKEPUB, error)
} }
} }
func NewOPDSHandler(db *database.Queries, conversionService interface { func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryService, conversionService interface {
ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*services.ConvertedKEPUB, error) ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*services.ConvertedKEPUB, error)
}) *OPDSHandler { }) *OPDSHandler {
return &OPDSHandler{ return &OPDSHandler{
db: db, db: db,
libraryService: libraryService,
conversionService: conversionService, conversionService: conversionService,
} }
} }
@@ -522,13 +524,19 @@ func (h *OPDSHandler) GetCoverImage(c echo.Context) error {
coverPath := mediaItem.CoverImagePath.String 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 // Check if file exists
if _, err := os.Stat(coverPath); os.IsNotExist(err) { if _, err := os.Stat(fullPath); os.IsNotExist(err) {
return c.NoContent(http.StatusNoContent) return c.NoContent(http.StatusNoContent)
} }
// Open file // Open file
file, err := os.Open(coverPath) file, err := os.Open(fullPath)
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to open cover"}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to open cover"})
} }
@@ -541,7 +549,7 @@ func (h *OPDSHandler) GetCoverImage(c echo.Context) error {
} }
// Determine content type // Determine content type
ext := strings.ToLower(filepath.Ext(coverPath)) ext := strings.ToLower(filepath.Ext(fullPath))
contentType := "image/jpeg" contentType := "image/jpeg"
if ext == ".png" { if ext == ".png" {
contentType = "image/png" contentType = "image/png"
+3 -8
View File
@@ -3,6 +3,7 @@ package handlers
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
wsync "bookhoard/internal/sync" wsync "bookhoard/internal/sync"
"bookhoard/internal/utils"
"context" "context"
"net/http" "net/http"
"strconv" "strconv"
@@ -283,10 +284,7 @@ func (h *Handler) GetAllProgress(c echo.Context) error {
continue continue
} }
coverPath := "" coverPath := utils.ResolveMediaURL(mediaItem.LibraryID, mediaItem.CoverImagePath)
if mediaItem.CoverImagePath.Valid {
coverPath = mediaItem.CoverImagePath.String
}
author := "" author := ""
if mediaItem.Author.Valid { if mediaItem.Author.Valid {
@@ -354,10 +352,7 @@ func (h *Handler) GetAllProgressData(c echo.Context) ([]ProgressWithMedia, error
continue continue
} }
coverPath := "" coverPath := utils.ResolveMediaURL(mediaItem.LibraryID, mediaItem.CoverImagePath)
if mediaItem.CoverImagePath.Valid {
coverPath = mediaItem.CoverImagePath.String
}
author := "" author := ""
if mediaItem.Author.Valid { if mediaItem.Author.Valid {
+6 -3
View File
@@ -47,9 +47,6 @@ func registerMediaRoutes(cfg *Config) {
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem) admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem) admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem)
// Download route (public)
e.GET("/api/media-items/:uuid/download", cfg.MediaHandler.DownloadBook)
// Shelf management (protected) // Shelf management (protected)
protected.POST("/devices/:id/shelves", cfg.MediaHandler.AddToShelf) protected.POST("/devices/:id/shelves", cfg.MediaHandler.AddToShelf)
protected.GET("/devices/:id/shelves", cfg.MediaHandler.GetShelf) protected.GET("/devices/:id/shelves", cfg.MediaHandler.GetShelf)
@@ -60,4 +57,10 @@ func registerMediaRoutes(cfg *Config) {
mediaItems := protected.Group("/media-items") mediaItems := protected.Group("/media-items")
mediaItems.POST("/bulk-delete", cfg.MediaHandler.HandleBulkDelete) mediaItems.POST("/bulk-delete", cfg.MediaHandler.HandleBulkDelete)
mediaItems.POST("/bulk-update", cfg.MediaHandler.HandleBulkUpdate) mediaItems.POST("/bulk-update", cfg.MediaHandler.HandleBulkUpdate)
// File serving - authenticated (registered on Echo to avoid /api prefix)
// Create a group with JWT middleware for routes outside /api
authenticated := e.Group("", createJWTMiddleware(cfg))
// Note: Must be registered LAST as it's a wildcard route
authenticated.GET("/uploads/library-:id/*", cfg.MediaHandler.ServeFile)
} }
+55 -1
View File
@@ -31,7 +31,38 @@ const (
var AllowedExtensions = map[string][]string{ var AllowedExtensions = map[string][]string{
LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".doc", ".docx", ".lit", ".fb2", ".pdb"}, LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".doc", ".docx", ".lit", ".fb2", ".pdb"},
LibraryTypeComics: {".cbz", ".cbr", ".cb7", ".cbt", ".pdf"}, LibraryTypeComics: {".cbz", ".cbr", ".cb7", ".cbt", ".pdf"},
LibraryTypeManga: {".cbz", ".cbr", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}, LibraryTypeManga: {".cbz", ".cbr", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"},
}
var MimeTypes = map[string]string{
// Images (manga)
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".bmp": "image/bmp",
".webp": "image/webp",
".avif": "image/avif",
".tiff": "image/tiff",
".tif": "image/tiff",
// Comics
".cbz": "application/vnd.comicbook+zip",
".cbr": "application/vnd.comicbook-rar",
".cb7": "application/x-cb7",
".cbt": "application/x-cbt",
// Ebooks
".epub": "application/epub+zip",
".pdf": "application/pdf",
".mobi": "application/x-mobipocket-ebook",
".azw": "application/vnd.amazon.ebook",
".azw3": "application/vnd.amazon.ebook",
".txt": "text/plain",
".rtf": "application/rtf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".lit": "application/x-msreader",
".fb2": "application/x-fictionbook+xml",
".pdb": "application/vnd.palm",
} }
// GetLibraryTypes retrieves all available library types // GetLibraryTypes retrieves all available library types
@@ -254,3 +285,26 @@ func (s *LibraryService) BrowseDirectories(ctx context.Context, path string) ([]
return dirs, cleanPath, parentPath, nil return dirs, cleanPath, parentPath, nil
} }
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")
}
+27 -34
View File
@@ -26,6 +26,7 @@ import (
"time" "time"
"bookhoard/internal/sevenzip" "bookhoard/internal/sevenzip"
epub "github.com/ArcadiaLin/go-epub" epub "github.com/ArcadiaLin/go-epub"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -514,7 +515,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
if len(coverImage) > 0 && metadata.CoverPath == "" { if len(coverImage) > 0 && metadata.CoverPath == "" {
coverPath := path + ".cover.jpg" coverPath := path + ".cover.jpg"
if err := os.WriteFile(coverPath, coverImage, 0644); err == nil { if err := os.WriteFile(coverPath, coverImage, 0644); err == nil {
metadata.CoverPath = coverPath metadata.CoverPath = s.getRelativePath(coverPath)
} }
} }
fmt.Printf("Extracted comic metadata from %s: title=%s, series=%s, issue=%d\n", fmt.Printf("Extracted comic metadata from %s: title=%s, series=%s, issue=%d\n",
@@ -576,7 +577,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""}, Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""}, Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""}, Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
FilePath: path, FilePath: s.getRelativePath(path),
FileSize: pgtype.Int8{Int64: info.Size(), Valid: true}, FileSize: pgtype.Int8{Int64: info.Size(), Valid: true},
MimeType: pgtype.Text{String: s.getMimeType(path), Valid: true}, MimeType: pgtype.Text{String: s.getMimeType(path), Valid: true},
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""}, CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
@@ -614,7 +615,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
_, err = s.db.CreateMediaItemFormat(ctx, database.CreateMediaItemFormatParams{ _, err = s.db.CreateMediaItemFormat(ctx, database.CreateMediaItemFormatParams{
MediaItemID: createdItem.ID, MediaItemID: createdItem.ID,
FormatType: format.FormatType, FormatType: format.FormatType,
FilePath: pgtype.Text{String: format.FilePath, Valid: true}, FilePath: pgtype.Text{String: s.getRelativePath(format.FilePath), Valid: true},
FileSha256: pgtype.Text{String: format.FileSHA256, Valid: true}, FileSha256: pgtype.Text{String: format.FileSHA256, Valid: true},
FileSizeBytes: pgtype.Int8{Int64: format.FileSizeBytes, Valid: true}, FileSizeBytes: pgtype.Int8{Int64: format.FileSizeBytes, Valid: true},
MimeType: pgtype.Text{String: format.MimeType, Valid: true}, MimeType: pgtype.Text{String: format.MimeType, Valid: true},
@@ -642,13 +643,13 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
if err != nil { if err != nil {
fmt.Printf("Warning: failed to extract EPUB cover from %s: %v\n", path, err) fmt.Printf("Warning: failed to extract EPUB cover from %s: %v\n", path, err)
} else if coverPath != "" { } else if coverPath != "" {
metadata.CoverPath = coverPath metadata.CoverPath = s.getRelativePath(coverPath)
} }
// If no embedded cover, try sidecar // If no embedded cover, try sidecar
if metadata.CoverPath == "" { if metadata.CoverPath == "" {
sidecarCover := findSidecarCover(path) sidecarCover := findSidecarCover(path)
if sidecarCover != "" { if sidecarCover != "" {
metadata.CoverPath = sidecarCover metadata.CoverPath = s.getRelativePath(sidecarCover)
} }
} }
return metadata, nil return metadata, nil
@@ -845,7 +846,7 @@ func findCoverImageInZip(files []*zip.File) string {
for _, name := range coverNames { for _, name := range coverNames {
for _, f := range files { for _, f := range files {
if strings.ToLower(f.Name) == strings.ToLower(name) { if strings.EqualFold(f.Name, name) {
return f.Name return f.Name
} }
} }
@@ -926,7 +927,7 @@ func readFileFromZip(files []*zip.File, name string) ([]byte, error) {
func extractImageFromZip(files []*zip.File, imagePath, opfDir string) ([]byte, error) { func extractImageFromZip(files []*zip.File, imagePath, opfDir string) ([]byte, error) {
// Try direct match first // Try direct match first
for _, f := range files { for _, f := range files {
if strings.ToLower(f.Name) == strings.ToLower(imagePath) { if strings.EqualFold(f.Name, imagePath) {
rc, err := f.Open() rc, err := f.Open()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -939,7 +940,7 @@ func extractImageFromZip(files []*zip.File, imagePath, opfDir string) ([]byte, e
// Try resolved path // Try resolved path
resolvedPath := resolveOPFPath(opfDir, imagePath) resolvedPath := resolveOPFPath(opfDir, imagePath)
for _, f := range files { for _, f := range files {
if strings.ToLower(f.Name) == strings.ToLower(resolvedPath) { if strings.EqualFold(f.Name, resolvedPath) {
rc, err := f.Open() rc, err := f.Open()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1057,14 +1058,14 @@ func (s *MediaScanner) extractPDFMetadata(path string) (*MediaMetadata, error) {
if err != nil { if err != nil {
fmt.Printf("Warning: failed to extract PDF cover from %s: %v\n", path, err) fmt.Printf("Warning: failed to extract PDF cover from %s: %v\n", path, err)
} else if coverPath != "" { } else if coverPath != "" {
metadata.CoverPath = coverPath metadata.CoverPath = s.getRelativePath(coverPath)
} }
// If no embedded cover, try sidecar // If no embedded cover, try sidecar
if metadata.CoverPath == "" { if metadata.CoverPath == "" {
sidecarCover := findSidecarCover(path) sidecarCover := findSidecarCover(path)
if sidecarCover != "" { if sidecarCover != "" {
metadata.CoverPath = sidecarCover metadata.CoverPath = s.getRelativePath(sidecarCover)
} }
} }
@@ -1469,7 +1470,7 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.U
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""}, Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""}, Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""}, Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""}, CoverImagePath: pgtype.Text{String: s.getRelativePath(metadata.CoverPath), Valid: metadata.CoverPath != ""},
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""}, Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0}, SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
Tags: metadata.Tags, Tags: metadata.Tags,
@@ -1492,30 +1493,10 @@ func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath stri
func (s *MediaScanner) getMimeType(path string) string { func (s *MediaScanner) getMimeType(path string) string {
ext := strings.ToLower(filepath.Ext(path)) ext := strings.ToLower(filepath.Ext(path))
switch ext { if mime, ok := MimeTypes[ext]; ok {
case ".epub": return mime
return "application/epub+zip"
case ".pdf":
return "application/pdf"
case ".mobi":
return "application/x-mobipocket-ebook"
case ".azw3":
return "application/vnd.amazon.ebook"
case ".fb2":
return "application/x-fictionbook+xml"
case ".txt":
return "text/plain"
case ".cbz":
return "application/vnd.comicbook+zip"
case ".cbr":
return "application/vnd.comicbook-rar"
case ".cb7":
return "application/x-7z-compressed"
case ".cbt":
return "application/x-tar"
default:
return "application/octet-stream"
} }
return "application/octet-stream"
} }
func (s *MediaScanner) WatchChanges(ctx context.Context) { func (s *MediaScanner) WatchChanges(ctx context.Context) {
@@ -1811,3 +1792,15 @@ func (s *MediaScanner) extractHashInfo(filePath string) (*HashInfo, *FormatInfo,
return hashInfo, formatInfo, nil return hashInfo, formatInfo, nil
} }
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 relPath, ok := strings.CutPrefix(absolutePath, baseFolder); ok {
return strings.TrimPrefix(relPath, "/")
}
}
// Fallback: if no match, return as-is (shouldn't happen)
return absolutePath
}
+28
View File
@@ -0,0 +1,28 @@
package utils
import (
"fmt"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
// resolveMediaURL resolves a relative path (cover or file) to a full URL
func ResolveMediaURL(libraryID pgtype.UUID, relativePath pgtype.Text) string {
if !relativePath.Valid || relativePath.String == "" {
return ""
}
if strings.HasPrefix(relativePath.String, "/uploads/") {
return relativePath.String
}
if filepath.IsAbs(relativePath.String) {
return relativePath.String
}
libraryIDStr := uuid.UUID(libraryID.Bytes).String()
return fmt.Sprintf("/uploads/library-%s/%s", libraryIDStr, relativePath.String)
}