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) +}