From 209e9f2a3cc81d9b0c46c2dfd58ca8aae990e8c6 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 27 Feb 2026 16:51:44 -0500 Subject: [PATCH] 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. --- cmd/server/main.go | 4 +- cmd/server/tests/test_helpers.go | 4 +- cover_image_serving_plan.md | 59 ++++++------ internal/database/queries.sql.go | 4 +- internal/database/queries/queries.sql | 2 +- internal/handlers/collections.go | 15 ++- internal/handlers/media.go | 130 +++++++++++++++++++++++++- internal/handlers/opds.go | 16 +++- internal/handlers/progress.go | 11 +-- internal/router/media.go | 9 +- internal/services/library_service.go | 56 ++++++++++- internal/services/media_scanner.go | 61 ++++++------ internal/utils/mediaurl.go | 28 ++++++ 13 files changed, 300 insertions(+), 99 deletions(-) create mode 100644 internal/utils/mediaurl.go diff --git a/cmd/server/main.go b/cmd/server/main.go index df0f3e4..bed9e1d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -115,10 +115,10 @@ func main() { // Create conversion service for EPUB→KEPUB conversion conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub") - opdsHandler := handlers.NewOPDSHandler(queries, conversionService) + opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService) // NEW: Create refactored handlers - collectionHandler := handlers.NewCollectionHandler(queries, connManager) + collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager) dashboardService := services.NewDashboardService(queries) dashboardHandler := handlers.NewDashboardHandler(queries) mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker) diff --git a/cmd/server/tests/test_helpers.go b/cmd/server/tests/test_helpers.go index 4c86779..a8caf0b 100644 --- a/cmd/server/tests/test_helpers.go +++ b/cmd/server/tests/test_helpers.go @@ -459,7 +459,7 @@ func setupTestServer(t *testing.T) *TestServerSetup { // Create refactored handlers (matching main.go) libraryService := services.NewLibraryService(queries) worker := services.NewWorker(3) - collectionHandler := handlers.NewCollectionHandler(queries, connManager) + collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager) dashboardService := services.NewDashboardService(queries) dashboardHandler := handlers.NewDashboardHandler(queries) mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker) @@ -467,7 +467,7 @@ func setupTestServer(t *testing.T) *TestServerSetup { // Create conversion service for OPDS conversionService := services.NewConversionService(queries, getCachePath()) - opdsHandler := handlers.NewOPDSHandler(queries, conversionService) + opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService) // Create Echo instance e := echo.New() diff --git a/cover_image_serving_plan.md b/cover_image_serving_plan.md index cb79d62..ffa9522 100644 --- a/cover_image_serving_plan.md +++ b/cover_image_serving_plan.md @@ -741,29 +741,12 @@ coverPath := h.resolveCoverURL(mediaItem.LibraryID, mediaItem.CoverImagePath) **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. - -**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: - +Add to imports: ```go -// Add this function somewhere in media.go -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 -} +"bookhoard/internal/utils" ``` -**Note**: The `ListMediaItems` and `GetMediaItem` functions currently return raw database rows. To properly resolve URLs, you would need to either: - -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): +**GetMediaItem** - Find where it returns the response (around line 770): **Current code**: ```go @@ -773,17 +756,23 @@ return c.JSON(http.StatusOK, item) **New code**: ```go 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": mh.ResolveCoverURL(item.LibraryID, item.CoverImagePath), - "file_path": mh.ResolveFileURL(item.LibraryID, item.FilePath), + "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 }) ``` -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 | |------|---------| -| `internal/handlers/media.go` | Add `ResolveCoverURL()`, `ResolveFileURL()`, `resolveMediaURL()` helpers | -| `internal/handlers/collections.go` | Add `libraryService` to struct and constructor; add `resolveCoverURL()`, `resolveFileURL()` helpers; update lines 193-201, 620-641, 910-919 | -| `internal/handlers/progress.go` | Add `resolveCoverURL()` to Handler (in commonhandlers.go); update lines 286-289, 357-360 | -| `internal/handlers/media.go` | Update `GetMediaItem` to return resolved URLs in response map | +| `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: diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 2d47946..088541c 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -1639,7 +1639,7 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection } 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 JOIN media_items mi ON ci.media_item_id = mi.id WHERE ci.collection_id = $1 @@ -1656,6 +1656,7 @@ type GetCollectionItemsRow struct { Title string `db:"title" json:"title"` Author pgtype.Text `db:"author" json:"author"` CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` } // Get collection items @@ -1678,6 +1679,7 @@ func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UU &i.Title, &i.Author, &i.CoverImagePath, + &i.LibraryID, ); err != nil { return nil, err } diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 2a0a842..d7a8af8 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -1363,7 +1363,7 @@ DELETE FROM collection_items WHERE collection_id = $1 AND media_item_id = $2; -- Get collection items -- 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 JOIN media_items mi ON ci.media_item_id = mi.id WHERE ci.collection_id = $1 diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index 4075224..f069ac1 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -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), } } diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 1fc2c35..9986ef4 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -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) +} diff --git a/internal/handlers/opds.go b/internal/handlers/opds.go index 2ed10ef..1827ab6 100644 --- a/internal/handlers/opds.go +++ b/internal/handlers/opds.go @@ -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" diff --git a/internal/handlers/progress.go b/internal/handlers/progress.go index 5e692a7..350c9d2 100644 --- a/internal/handlers/progress.go +++ b/internal/handlers/progress.go @@ -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 { diff --git a/internal/router/media.go b/internal/router/media.go index 1feb8c8..e157235 100644 --- a/internal/router/media.go +++ b/internal/router/media.go @@ -47,9 +47,6 @@ func registerMediaRoutes(cfg *Config) { admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem) admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem) - // Download route (public) - e.GET("/api/media-items/:uuid/download", cfg.MediaHandler.DownloadBook) - // Shelf management (protected) protected.POST("/devices/:id/shelves", cfg.MediaHandler.AddToShelf) protected.GET("/devices/:id/shelves", cfg.MediaHandler.GetShelf) @@ -60,4 +57,10 @@ func registerMediaRoutes(cfg *Config) { mediaItems := protected.Group("/media-items") mediaItems.POST("/bulk-delete", cfg.MediaHandler.HandleBulkDelete) 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) } diff --git a/internal/services/library_service.go b/internal/services/library_service.go index 28e7d2a..e7b3903 100644 --- a/internal/services/library_service.go +++ b/internal/services/library_service.go @@ -31,7 +31,38 @@ const ( var AllowedExtensions = map[string][]string{ LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".doc", ".docx", ".lit", ".fb2", ".pdb"}, 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 @@ -254,3 +285,26 @@ func (s *LibraryService) BrowseDirectories(ctx context.Context, path string) ([] 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") +} diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index 432898c..35cbd87 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -26,6 +26,7 @@ import ( "time" "bookhoard/internal/sevenzip" + epub "github.com/ArcadiaLin/go-epub" "github.com/fsnotify/fsnotify" "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 == "" { coverPath := path + ".cover.jpg" 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", @@ -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 != ""}, Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""}, Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""}, - FilePath: path, + FilePath: s.getRelativePath(path), FileSize: pgtype.Int8{Int64: info.Size(), Valid: true}, MimeType: pgtype.Text{String: s.getMimeType(path), Valid: true}, 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{ MediaItemID: createdItem.ID, 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}, FileSizeBytes: pgtype.Int8{Int64: format.FileSizeBytes, 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 { fmt.Printf("Warning: failed to extract EPUB cover from %s: %v\n", path, err) } else if coverPath != "" { - metadata.CoverPath = coverPath + metadata.CoverPath = s.getRelativePath(coverPath) } // If no embedded cover, try sidecar if metadata.CoverPath == "" { sidecarCover := findSidecarCover(path) if sidecarCover != "" { - metadata.CoverPath = sidecarCover + metadata.CoverPath = s.getRelativePath(sidecarCover) } } return metadata, nil @@ -845,7 +846,7 @@ func findCoverImageInZip(files []*zip.File) string { for _, name := range coverNames { for _, f := range files { - if strings.ToLower(f.Name) == strings.ToLower(name) { + if strings.EqualFold(f.Name, 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) { // Try direct match first for _, f := range files { - if strings.ToLower(f.Name) == strings.ToLower(imagePath) { + if strings.EqualFold(f.Name, imagePath) { rc, err := f.Open() if err != nil { return nil, err @@ -939,7 +940,7 @@ func extractImageFromZip(files []*zip.File, imagePath, opfDir string) ([]byte, e // Try resolved path resolvedPath := resolveOPFPath(opfDir, imagePath) for _, f := range files { - if strings.ToLower(f.Name) == strings.ToLower(resolvedPath) { + if strings.EqualFold(f.Name, resolvedPath) { rc, err := f.Open() if err != nil { return nil, err @@ -1057,14 +1058,14 @@ func (s *MediaScanner) extractPDFMetadata(path string) (*MediaMetadata, error) { if err != nil { fmt.Printf("Warning: failed to extract PDF cover from %s: %v\n", path, err) } else if coverPath != "" { - metadata.CoverPath = coverPath + metadata.CoverPath = s.getRelativePath(coverPath) } // If no embedded cover, try sidecar if metadata.CoverPath == "" { sidecarCover := findSidecarCover(path) 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 != ""}, Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""}, 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 != ""}, SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0}, Tags: metadata.Tags, @@ -1492,30 +1493,10 @@ func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath stri func (s *MediaScanner) getMimeType(path string) string { ext := strings.ToLower(filepath.Ext(path)) - switch ext { - case ".epub": - 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" + if mime, ok := MimeTypes[ext]; ok { + return mime } + return "application/octet-stream" } func (s *MediaScanner) WatchChanges(ctx context.Context) { @@ -1811,3 +1792,15 @@ func (s *MediaScanner) extractHashInfo(filePath string) (*HashInfo, *FormatInfo, 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 +} diff --git a/internal/utils/mediaurl.go b/internal/utils/mediaurl.go new file mode 100644 index 0000000..542b6ba --- /dev/null +++ b/internal/utils/mediaurl.go @@ -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) +}