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:
@@ -4,6 +4,7 @@ import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
wsync "bookhoard/internal/sync"
|
||||
"bookhoard/internal/utils"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -20,13 +21,15 @@ import (
|
||||
type CollectionHandler struct {
|
||||
db *database.Queries
|
||||
collectionService *services.CollectionService
|
||||
libraryService *services.LibraryService
|
||||
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{
|
||||
db: db,
|
||||
collectionService: services.NewCollectionService(db),
|
||||
libraryService: libraryService,
|
||||
connManager: connManager,
|
||||
}
|
||||
}
|
||||
@@ -196,7 +199,7 @@ func (h *CollectionHandler) GetCollection(c echo.Context) error {
|
||||
MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(),
|
||||
Title: book.Title,
|
||||
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 {
|
||||
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
|
||||
@@ -634,7 +633,7 @@ func (h *CollectionHandler) TestRules(c echo.Context) error {
|
||||
MediaItemID: uuid.UUID(item.ID.Bytes).String(),
|
||||
Title: item.Title,
|
||||
Author: author,
|
||||
CoverImagePath: coverPath,
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
MatchReason: matchReason,
|
||||
})
|
||||
}
|
||||
@@ -914,7 +913,7 @@ func (h *CollectionHandler) PreviewCollection(c echo.Context) error {
|
||||
MediaItemID: itemUUID.String(),
|
||||
Title: item.Title,
|
||||
Author: textToString(item.Author),
|
||||
CoverImagePath: textToString(item.CoverImagePath),
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+126
-4
@@ -4,12 +4,15 @@ import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"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"})
|
||||
}
|
||||
|
||||
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"})
|
||||
}
|
||||
|
||||
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 {
|
||||
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.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
|
||||
@@ -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.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
|
||||
@@ -1407,3 +1471,61 @@ func (mh *MediaHandler) SearchMediaItems(c echo.Context) error {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -22,16 +22,18 @@ import (
|
||||
|
||||
type OPDSHandler struct {
|
||||
db *database.Queries
|
||||
libraryService *services.LibraryService
|
||||
conversionService interface {
|
||||
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)
|
||||
}) *OPDSHandler {
|
||||
return &OPDSHandler{
|
||||
db: db,
|
||||
libraryService: libraryService,
|
||||
conversionService: conversionService,
|
||||
}
|
||||
}
|
||||
@@ -522,13 +524,19 @@ 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(coverPath); os.IsNotExist(err) {
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Open file
|
||||
file, err := os.Open(coverPath)
|
||||
file, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
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
|
||||
ext := strings.ToLower(filepath.Ext(coverPath))
|
||||
ext := strings.ToLower(filepath.Ext(fullPath))
|
||||
contentType := "image/jpeg"
|
||||
if ext == ".png" {
|
||||
contentType = "image/png"
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
wsync "bookhoard/internal/sync"
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -283,10 +284,7 @@ func (h *Handler) GetAllProgress(c echo.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
coverPath := ""
|
||||
if mediaItem.CoverImagePath.Valid {
|
||||
coverPath = mediaItem.CoverImagePath.String
|
||||
}
|
||||
coverPath := utils.ResolveMediaURL(mediaItem.LibraryID, mediaItem.CoverImagePath)
|
||||
|
||||
author := ""
|
||||
if mediaItem.Author.Valid {
|
||||
@@ -354,10 +352,7 @@ func (h *Handler) GetAllProgressData(c echo.Context) ([]ProgressWithMedia, error
|
||||
continue
|
||||
}
|
||||
|
||||
coverPath := ""
|
||||
if mediaItem.CoverImagePath.Valid {
|
||||
coverPath = mediaItem.CoverImagePath.String
|
||||
}
|
||||
coverPath := utils.ResolveMediaURL(mediaItem.LibraryID, mediaItem.CoverImagePath)
|
||||
|
||||
author := ""
|
||||
if mediaItem.Author.Valid {
|
||||
|
||||
Reference in New Issue
Block a user