From 6495cc2c7c633e5cbb09fcfabdc8cd41cfa5b31f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 31 Jan 2026 22:32:23 -0500 Subject: [PATCH] feat(handlers): Add OPDS, collections, book matching, and sync handlers - Add OPDS handler for device catalog and book downloads - Add collections handler for collection CRUD - Add book matching service for cross-device book linking - Add sidecar handler for Kobo metadata sync - Add sync handler for device synchronization --- internal/handlers/book_matching.go | 334 ++++++++++++ internal/handlers/collections.go | 489 ++++++++++++++++++ internal/handlers/opds.go | 796 +++++++++++++++++++++++++++++ internal/handlers/sidecar.go | 423 +++++++++++++++ internal/handlers/sync.go | 154 ++++++ 5 files changed, 2196 insertions(+) create mode 100644 internal/handlers/book_matching.go create mode 100644 internal/handlers/collections.go create mode 100644 internal/handlers/opds.go create mode 100644 internal/handlers/sidecar.go create mode 100644 internal/handlers/sync.go diff --git a/internal/handlers/book_matching.go b/internal/handlers/book_matching.go new file mode 100644 index 0000000..4970e3b --- /dev/null +++ b/internal/handlers/book_matching.go @@ -0,0 +1,334 @@ +package handlers + +import ( + "bookmann/internal/database" + "bookmann/internal/services" + "fmt" + "net/http" + "strconv" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" +) + +// Book matching service (added to Handler struct in ebook.go initialization) +func (h *Handler) getMatchingService() *services.BookMatchingService { + return services.NewBookMatchingService(h.db) +} + +// QueryBooks handles POST /api/sync/books/query +func (h *Handler) QueryBooks(c echo.Context) error { + matchingService := h.getMatchingService() + + var req services.BookQueryRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid request body", + }) + } + + response, err := matchingService.QueryBooks(c.Request().Context(), &req) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to query books", + }) + } + + return c.JSON(http.StatusOK, response) +} + +// LinkBook handles POST /api/sync/link-book +func (h *Handler) LinkBook(c echo.Context) error { + matchingService := h.getMatchingService() + + var req services.LinkBookRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid request body", + }) + } + + // Get device ID from context (set by auth middleware) + deviceIDStr := c.Param("deviceId") + deviceID, err := uuid.Parse(deviceIDStr) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid device ID", + }) + } + + alias, err := matchingService.LinkBook(c.Request().Context(), deviceID, &req) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": fmt.Sprintf("Failed to link book: %v", err), + }) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "status": "linked", + "device_file_alias": map[string]interface{}{ + "id": uuid.UUID(alias.ID.Bytes).String(), + "media_item_id": uuid.UUID(alias.MediaItemID.Bytes).String(), + "device_id": uuid.UUID(alias.DeviceID.Bytes).String(), + "file_path": alias.FilePath, + "file_sha256": alias.FileSha256.String, + "confidence_score": alias.ConfidenceScore.Float64, + }, + }) +} + +// GetUnlinkedBooks handles GET /api/sync/unlinked-books +func (h *Handler) GetUnlinkedBooks(c echo.Context) error { + matchingService := h.getMatchingService() + + deviceIDStr := c.Param("deviceId") + deviceID, err := uuid.Parse(deviceIDStr) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid device ID", + }) + } + + unlinked, err := matchingService.GetUnlinkedBooks(c.Request().Context(), deviceID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to get unlinked books", + }) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "unlinked": unlinked, + "total": len(unlinked), + }) +} + +// GetDeviceFileAliases handles GET /api/devices/:id/file-aliases +func (h *Handler) GetDeviceFileAliases(c echo.Context) error { + deviceIDStr := c.Param("id") + deviceID, err := uuid.Parse(deviceIDStr) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid device ID", + }) + } + + aliases, err := h.db.GetDeviceFileAliasesByDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceID, Valid: true}) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to get file aliases", + }) + } + + // Convert to response format + type AliasResponse struct { + ID string `json:"id"` + MediaItemID string `json:"media_item_id"` + FilePath string `json:"file_path"` + FileSHA256 string `json:"file_sha256"` + ConfidenceScore float64 `json:"confidence_score"` + LastSeenAt string `json:"last_seen_at"` + } + + response := make([]AliasResponse, len(aliases)) + for i, alias := range aliases { + response[i] = AliasResponse{ + ID: uuid.UUID(alias.ID.Bytes).String(), + MediaItemID: uuid.UUID(alias.MediaItemID.Bytes).String(), + FilePath: alias.FilePath, + FileSHA256: alias.FileSha256.String, + ConfidenceScore: alias.ConfidenceScore.Float64, + LastSeenAt: alias.LastSeenAt.Time.String(), + } + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "device_id": deviceIDStr, + "aliases": response, + "total": len(response), + }) +} + +// CreateDeviceFileAlias handles POST /api/devices/:id/file-aliases +func (h *Handler) CreateDeviceFileAlias(c echo.Context) error { + deviceIDStr := c.Param("id") + deviceID, err := uuid.Parse(deviceIDStr) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid device ID", + }) + } + + var req struct { + MediaItemID string `json:"media_item_id"` + FilePath string `json:"file_path"` + FileSHA256 string `json:"file_sha256"` + ConfidenceScore float64 `json:"confidence_score"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid request body", + }) + } + + mediaItemID, err := uuid.Parse(req.MediaItemID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid media item ID", + }) + } + + alias, err := h.db.CreateDeviceFileAlias(c.Request().Context(), database.CreateDeviceFileAliasParams{ + MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, + DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true}, + FilePath: req.FilePath, + FileSha256: pgtype.Text{String: req.FileSHA256, Valid: req.FileSHA256 != ""}, + ConfidenceScore: pgtype.Float8{Float64: req.ConfidenceScore, Valid: true}, + }) + + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": fmt.Sprintf("Failed to create file alias: %v", err), + }) + } + + return c.JSON(http.StatusCreated, map[string]interface{}{ + "id": uuid.UUID(alias.ID.Bytes).String(), + "media_item_id": uuid.UUID(alias.MediaItemID.Bytes).String(), + "device_id": deviceIDStr, + "file_path": alias.FilePath, + "file_sha256": alias.FileSha256.String, + "confidence_score": alias.ConfidenceScore.Float64, + "last_seen_at": alias.LastSeenAt.Time.String(), + }) +} + +// UpdateDeviceFileAlias handles PUT /api/devices/:id/file-aliases/:aliasId +func (h *Handler) UpdateDeviceFileAlias(c echo.Context) error { + aliasIDStr := c.Param("aliasId") + aliasID, err := uuid.Parse(aliasIDStr) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid alias ID", + }) + } + + var req struct { + MediaItemID *string `json:"media_item_id"` + FileSHA256 *string `json:"file_sha256"` + ConfidenceScore *float64 `json:"confidence_score"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid request body", + }) + } + + // Build update parameters + var mediaItemID pgtype.UUID + if req.MediaItemID != nil { + parsedID, err := uuid.Parse(*req.MediaItemID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid media item ID", + }) + } + mediaItemID = pgtype.UUID{Bytes: parsedID, Valid: true} + } + + var fileSHA256 pgtype.Text + if req.FileSHA256 != nil { + fileSHA256 = pgtype.Text{String: *req.FileSHA256, Valid: true} + } + + var confidenceScore pgtype.Float8 + if req.ConfidenceScore != nil { + confidenceScore = pgtype.Float8{Float64: *req.ConfidenceScore, Valid: true} + } + + alias, err := h.db.UpdateDeviceFileAlias(c.Request().Context(), database.UpdateDeviceFileAliasParams{ + ID: pgtype.UUID{Bytes: aliasID, Valid: true}, + MediaItemID: mediaItemID, + FileSha256: fileSHA256, + ConfidenceScore: confidenceScore, + }) + + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": fmt.Sprintf("Failed to update file alias: %v", err), + }) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "id": uuid.UUID(alias.ID.Bytes).String(), + "media_item_id": uuid.UUID(alias.MediaItemID.Bytes).String(), + "file_path": alias.FilePath, + "file_sha256": alias.FileSha256.String, + "confidence_score": alias.ConfidenceScore.Float64, + "last_seen_at": alias.LastSeenAt.Time.String(), + }) +} + +// DeleteDeviceFileAlias handles DELETE /api/devices/:id/file-aliases/:aliasId +func (h *Handler) DeleteDeviceFileAlias(c echo.Context) error { + aliasIDStr := c.Param("aliasId") + aliasID, err := uuid.Parse(aliasIDStr) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid alias ID", + }) + } + + err = h.db.DeleteDeviceFileAlias(c.Request().Context(), pgtype.UUID{Bytes: aliasID, Valid: true}) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to delete file alias", + }) + } + + return c.JSON(http.StatusOK, map[string]string{ + "message": "File alias deleted successfully", + }) +} + +// GetBookMatches handles GET /api/books/match +func (h *Handler) GetBookMatches(c echo.Context) error { + matchingService := h.getMatchingService() + + // Get query parameters + identifiers := c.QueryParams()["identifier"] + sha256 := c.QueryParam("sha256") + title := c.QueryParam("title") + author := c.QueryParam("author") + fileSizeStr := c.QueryParam("file_size") + + var fileSize int64 + if fileSizeStr != "" { + size, err := strconv.ParseInt(fileSizeStr, 10, 64) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid file_size parameter", + }) + } + fileSize = size + } + + req := &services.BookQueryRequest{ + Identifiers: identifiers, + SHA256: sha256, + Title: title, + Author: author, + FileSize: fileSize, + } + + response, err := matchingService.QueryBooks(c.Request().Context(), req) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to query books", + }) + } + + return c.JSON(http.StatusOK, response) +} diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go new file mode 100644 index 0000000..f2b4c6b --- /dev/null +++ b/internal/handlers/collections.go @@ -0,0 +1,489 @@ +package handlers + +import ( + "bookmann/internal/database" + "bookmann/internal/services" + "encoding/json" + "net/http" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" +) + +type CollectionHandler struct { + db *database.Queries + collectionService *services.CollectionService +} + +func NewCollectionHandler(db *database.Queries) *CollectionHandler { + return &CollectionHandler{ + db: db, + collectionService: services.NewCollectionService(db), + } +} + +type CreateCollectionRequest struct { + Name string `json:"name" validate:"required"` + Description string `json:"description"` + Color string `json:"color"` + Icon string `json:"icon"` + AutoAssignRules []services.Rule `json:"auto_assign_rules"` + ViewSettings map[string]interface{} `json:"view_settings"` +} + +type UpdateCollectionRequest struct { + Name string `json:"name" validate:"required"` + Description string `json:"description"` + Color string `json:"color"` + Icon string `json:"icon"` + AutoAssignRules []services.Rule `json:"auto_assign_rules"` + ViewSettings map[string]interface{} `json:"view_settings"` +} + +type AddBooksRequest struct { + BookIDs []string `json:"book_ids" validate:"required"` +} + +type CreateDeviceMappingRequest struct { + CollectionID string `json:"collection_id" validate:"required"` + DeviceShelfName string `json:"device_shelf_name" validate:"required"` + SyncDirection string `json:"sync_direction" validate:"required,oneof=bidirectional book_to_device device_to_book none"` +} + +type UpdateDeviceMappingRequest struct { + DeviceShelfName string `json:"device_shelf_name" validate:"required"` + SyncDirection string `json:"sync_direction" validate:"required,oneof=bidirectional book_to_device device_to_book none"` +} + +func (h *CollectionHandler) CreateCollection(c echo.Context) error { + user := c.Get("user").(database.Users) + userUUID := uuid.UUID(user.ID.Bytes) + + var req CreateCollectionRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + if err := c.Validate(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + collection, err := h.collectionService.CreateCollection( + c.Request().Context(), + userUUID, + req.Name, + req.Description, + req.Color, + req.Icon, + req.AutoAssignRules, + req.ViewSettings, + ) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + bookCount := int32(0) + return c.JSON(http.StatusCreated, map[string]interface{}{ + "id": uuid.UUID(collection.ID.Bytes).String(), + "user_id": uuid.UUID(collection.UserID.Bytes).String(), + "name": collection.Name, + "description": textToString(collection.Description), + "color": textToString(collection.Color), + "icon": textToString(collection.Icon), + "auto_assign_rules": collection.AutoAssignRules, + "view_settings": collection.ViewSettings, + "book_count": bookCount, + "created_at": collection.CreatedAt.Time.String(), + }) +} + +func (h *CollectionHandler) GetCollections(c echo.Context) error { + includeAuto := c.QueryParam("include_auto") == "true" + sortBy := c.QueryParam("sort_by") + + collections, err := h.GetCollectionsData(c) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + type CollectionResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Color string `json:"color"` + Icon string `json:"icon"` + AutoAssignRules json.RawMessage `json:"auto_assign_rules"` + CreatedAt string `json:"created_at"` + } + + response := make([]CollectionResponse, 0, len(collections)) + for _, col := range collections { + if !includeAuto && len(col.AutoAssignRules) > 0 { + continue + } + response = append(response, CollectionResponse{ + ID: uuid.UUID(col.ID.Bytes), + Name: col.Name, + Description: textToString(col.Description), + Color: textToString(col.Color), + Icon: textToString(col.Icon), + AutoAssignRules: json.RawMessage(col.AutoAssignRules), + CreatedAt: col.CreatedAt.Time.String(), + }) + } + + if sortBy == "name" { + for i := 0; i < len(response); i++ { + for j := i + 1; j < len(response); j++ { + if response[i].Name > response[j].Name { + response[i], response[j] = response[j], response[i] + } + } + } + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "collections": response, + "total": len(response), + }) +} + +func (h *CollectionHandler) GetCollection(c echo.Context) error { + collectionID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) + } + + collection, err := h.GetCollectionData(c, collectionID) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"}) + } + + books, err := h.GetCollectionBooksData(c, collectionID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + type BookInfo struct { + MediaItemID string `json:"media_item_id"` + Title string `json:"title"` + Author string `json:"author"` + CoverImagePath string `json:"cover_image_path"` + } + + bookList := make([]BookInfo, 0, len(books)) + for _, book := range books { + bookList = append(bookList, BookInfo{ + MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(), + Title: book.Title, + Author: textToString(book.Author), + CoverImagePath: textToString(book.CoverImagePath), + }) + } + + var viewSettings map[string]interface{} + if len(collection.ViewSettings) > 0 { + json.Unmarshal(collection.ViewSettings, &viewSettings) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "id": uuid.UUID(collection.ID.Bytes).String(), + "user_id": uuid.UUID(collection.UserID.Bytes).String(), + "name": collection.Name, + "description": textToString(collection.Description), + "color": textToString(collection.Color), + "icon": textToString(collection.Icon), + "auto_assign_rules": json.RawMessage(collection.AutoAssignRules), + "view_settings": viewSettings, + "books": bookList, + "book_count": len(bookList), + "created_at": collection.CreatedAt.Time.String(), + }) +} + +func (h *CollectionHandler) UpdateCollection(c echo.Context) error { + collectionID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) + } + + var req UpdateCollectionRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + if err := c.Validate(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + collection, err := h.collectionService.UpdateCollection( + c.Request().Context(), + collectionID, + req.Name, + req.Description, + req.Color, + req.Icon, + req.AutoAssignRules, + req.ViewSettings, + ) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "id": uuid.UUID(collection.ID.Bytes).String(), + "user_id": uuid.UUID(collection.UserID.Bytes).String(), + "name": collection.Name, + "description": textToString(collection.Description), + "color": textToString(collection.Color), + "icon": textToString(collection.Icon), + "auto_assign_rules": json.RawMessage(collection.AutoAssignRules), + "view_settings": json.RawMessage(collection.ViewSettings), + "created_at": collection.CreatedAt.Time.String(), + }) +} + +func (h *CollectionHandler) DeleteCollection(c echo.Context) error { + collectionID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) + } + + err = h.collectionService.DeleteCollection(c.Request().Context(), collectionID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.NoContent(http.StatusNoContent) +} + +func (h *CollectionHandler) AddBooks(c echo.Context) error { + collectionID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) + } + + user := c.Get("user").(database.Users) + userUUID := uuid.UUID(user.ID.Bytes) + + var req AddBooksRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + if err := c.Validate(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + for _, bookIDStr := range req.BookIDs { + bookID, err := uuid.Parse(bookIDStr) + if err != nil { + continue + } + h.collectionService.AddBookToCollection(c.Request().Context(), collectionID, bookID, userUUID) + } + + return c.NoContent(http.StatusNoContent) +} + +func (h *CollectionHandler) RemoveBook(c echo.Context) error { + collectionID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) + } + + bookID, err := uuid.Parse(c.Param("bookId")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"}) + } + + err = h.collectionService.RemoveBookFromCollection(c.Request().Context(), collectionID, bookID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.NoContent(http.StatusNoContent) +} + +func (h *CollectionHandler) GetDeviceMappings(c echo.Context) error { + deviceID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"}) + } + + mappings, err := h.collectionService.GetDeviceShelfMappings(c.Request().Context(), deviceID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + type MappingResponse struct { + ID uuid.UUID `json:"id"` + CollectionID uuid.UUID `json:"collection_id"` + CollectionName string `json:"collection_name"` + DeviceShelfName string `json:"device_shelf_name"` + SyncDirection string `json:"sync_direction"` + CreatedAt string `json:"created_at"` + } + + response := make([]MappingResponse, 0, len(mappings)) + for _, m := range mappings { + response = append(response, MappingResponse{ + ID: uuid.UUID(m.ID.Bytes), + CollectionID: uuid.UUID(m.CollectionID.Bytes), + CollectionName: m.CollectionName, + DeviceShelfName: textToString(m.DeviceShelfName), + SyncDirection: textToString(m.SyncDirection), + CreatedAt: m.CreatedAt.Time.String(), + }) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "device_id": deviceID.String(), + "mappings": response, + "total": len(response), + }) +} + +func (h *CollectionHandler) CreateDeviceMapping(c echo.Context) error { + deviceID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"}) + } + + var req CreateDeviceMappingRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + if err := c.Validate(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + collectionID, err := uuid.Parse(req.CollectionID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) + } + + mapping, err := h.collectionService.CreateDeviceShelfMapping( + c.Request().Context(), + collectionID, + deviceID, + req.DeviceShelfName, + req.SyncDirection, + ) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusCreated, map[string]interface{}{ + "id": uuid.UUID(mapping.ID.Bytes).String(), + "collection_id": uuid.UUID(mapping.CollectionID.Bytes).String(), + "device_id": uuid.UUID(mapping.DeviceID.Bytes).String(), + "device_shelf_name": textToString(mapping.DeviceShelfName), + "sync_direction": textToString(mapping.SyncDirection), + "created_at": mapping.CreatedAt.Time.String(), + }) +} + +func (h *CollectionHandler) UpdateDeviceMapping(c echo.Context) error { + mappingID, err := uuid.Parse(c.Param("mappingId")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid mapping id"}) + } + + var req UpdateDeviceMappingRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + if err := c.Validate(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + mapping, err := h.collectionService.UpdateDeviceShelfMapping( + c.Request().Context(), + mappingID, + req.DeviceShelfName, + req.SyncDirection, + ) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "id": uuid.UUID(mapping.ID.Bytes).String(), + "collection_id": uuid.UUID(mapping.CollectionID.Bytes).String(), + "device_id": uuid.UUID(mapping.DeviceID.Bytes).String(), + "device_shelf_name": textToString(mapping.DeviceShelfName), + "sync_direction": textToString(mapping.SyncDirection), + "created_at": mapping.CreatedAt.Time.String(), + }) +} + +func (h *CollectionHandler) DeleteDeviceMapping(c echo.Context) error { + mappingID, err := uuid.Parse(c.Param("mappingId")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid mapping id"}) + } + + err = h.collectionService.DeleteDeviceShelfMapping(c.Request().Context(), mappingID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.NoContent(http.StatusNoContent) +} + +func (h *CollectionHandler) GetBookCollections(c echo.Context) error { + bookID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"}) + } + + collections, err := h.collectionService.GetBookCollections(c.Request().Context(), bookID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + type CollectionResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Color string `json:"color"` + Icon string `json:"icon"` + } + + response := make([]CollectionResponse, 0, len(collections)) + for _, col := range collections { + response = append(response, CollectionResponse{ + ID: uuid.UUID(col.ID.Bytes), + Name: col.Name, + Description: textToString(col.Description), + Color: textToString(col.Color), + Icon: textToString(col.Icon), + }) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "book_id": bookID.String(), + "collections": response, + "total": len(response), + }) +} + +func textToString(t pgtype.Text) string { + if t.Valid { + return t.String + } + return "" +} + +func (h *CollectionHandler) GetCollectionsData(c echo.Context) ([]database.Collections, error) { + user := c.Get("user").(database.Users) + userUUID := uuid.UUID(user.ID.Bytes) + return h.collectionService.GetUserCollections(c.Request().Context(), userUUID) +} + +func (h *CollectionHandler) GetCollectionData(c echo.Context, collectionID uuid.UUID) (database.Collections, error) { + return h.collectionService.GetCollection(c.Request().Context(), collectionID) +} + +func (h *CollectionHandler) GetCollectionBooksData(c echo.Context, collectionID uuid.UUID) ([]database.GetCollectionItemsRow, error) { + return h.collectionService.GetCollectionBooks(c.Request().Context(), collectionID) +} diff --git a/internal/handlers/opds.go b/internal/handlers/opds.go new file mode 100644 index 0000000..a9326d8 --- /dev/null +++ b/internal/handlers/opds.go @@ -0,0 +1,796 @@ +package handlers + +import ( + "bookmann/internal/database" + "bookmann/internal/opds" + "crypto/rand" + "encoding/hex" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" +) + +type OPDSHandler struct { + db *database.Queries +} + +func NewOPDSHandler(db *database.Queries) *OPDSHandler { + return &OPDSHandler{ + db: db, + } +} + +// Helper function to get base URLs from system config +func (h *OPDSHandler) getBaseURLs(c echo.Context) (string, string, error) { + baseURL, err := h.db.GetSystemConfig(c.Request().Context(), "base_url") + if err != nil { + return "", "", fmt.Errorf("failed to get base_url from config: %w", err) + } + + opdsBaseURL, err := h.db.GetSystemConfig(c.Request().Context(), "opds_base_url") + if err != nil { + return "", "", fmt.Errorf("failed to get opds_base_url from config: %w", err) + } + + return baseURL.Value, opdsBaseURL.Value, nil +} + +// GetDeviceCatalog returns the OPDS catalog feed for a device +func (h *OPDSHandler) GetDeviceCatalog(c echo.Context) error { + deviceID := c.Param("deviceId") + page := c.QueryParam("page") + perPage := c.QueryParam("per_page") + includeFormat := c.QueryParam("include_format") + + // Parse pagination parameters + pageNum := 1 + if page != "" { + if num, err := strconv.Atoi(page); err == nil && num > 0 { + pageNum = num + } + } + + perPageNum := 50 + if perPage != "" { + if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= 200 { + perPageNum = num + } + } + + // Get base URLs + baseURL, opdsBaseURL, err := h.getBaseURLs(c) + if err != nil { + return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get config")) + } + + // Parse device ID + deviceUUID, err := uuid.Parse(deviceID) + if err != nil { + return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid device ID")) + } + + // Verify device exists + device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true}) + if err != nil { + return c.XML(http.StatusNotFound, opds.NewErrorFeed("Device not found")) + } + + // Get user's visible libraries + userID := device.UserID.Bytes + userUUID := uuid.UUID(userID) + + libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}) + if err != nil { + return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries")) + } + + // Get media items + var allItems []database.ListMediaItemsByLibraryRow + for _, lib := range libraries { + items, err := h.db.ListMediaItemsByLibrary(c.Request().Context(), pgtype.UUID{Bytes: lib.ID.Bytes, Valid: true}) + if err != nil { + continue + } + allItems = append(allItems, items...) + } + + // Filter by format if specified + if includeFormat != "" { + filteredItems := []database.ListMediaItemsByLibraryRow{} + for _, item := range allItems { + if strings.ToLower(item.FormatGroup) == strings.ToLower(includeFormat) { + filteredItems = append(filteredItems, item) + } + } + allItems = filteredItems + } + + // Pagination + totalItems := len(allItems) + startIdx := (pageNum - 1) * perPageNum + endIdx := startIdx + perPageNum + + if startIdx >= totalItems { + allItems = []database.ListMediaItemsByLibraryRow{} + } else if endIdx > totalItems { + allItems = allItems[startIdx:] + } else { + allItems = allItems[startIdx:endIdx] + } + + // Create OPDS feed + feed := opds.NewFeed( + fmt.Sprintf("urn:uuid:%s", deviceID), + "Bookmann Library", + ) + + // Add feed links + catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID) + feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self") + feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start") + + searchURL := fmt.Sprintf("%s/opds/devices/%s/search", opdsBaseURL, deviceID) + feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "search") + + // Add entries + for _, item := range allItems { + bookUUID := uuid.UUID(item.ID.Bytes).String() + title := item.Title + author := "" + if item.Author.Valid { + author = item.Author.String + } + + updated := item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z") + + entry := opds.NewEntry( + fmt.Sprintf("urn:uuid:%s", bookUUID), + title, + author, + updated, + ) + + // Add description + if item.Description.Valid { + entry.SetSummary(item.Description.String) + } + + // Add acquisition links + downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID) + entry.AddAcquisitionLink(downloadURL, "application/epub+zip") + + // Add format variants + kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL) + entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip") + + pdfURL := fmt.Sprintf("%s?format=pdf", downloadURL) + entry.AddAlternateLink(pdfURL, "application/pdf") + + // Add canonical identifier + entry.SetIdentifier(bookUUID) + + // Add SHA-256 metadata + if item.FileSha256.Valid { + entry.AddMetadata("bookmann:sha256", item.FileSha256.String) + } + + // Add collections as categories + collections, err := h.db.GetCollectionsForBook(c.Request().Context(), pgtype.UUID{Bytes: item.ID.Bytes, Valid: true}) + if err == nil { + collectionScheme := fmt.Sprintf("%s/collections", baseURL) + for _, col := range collections { + if col.UserID.Valid && uuid.UUID(col.UserID.Bytes) == userUUID { + entry.AddCategory(collectionScheme, col.Name) + } + } + } + + feed.AddEntry(entry) + } + + // Generate XML + xmlString, err := feed.GenerateXMLString() + if err != nil { + return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed")) + } + + c.Response().Header().Set("Content-Type", "application/atom+xml;profile=opds-catalog;kind=acquisition") + return c.String(http.StatusOK, xmlString) +} + +// SearchDeviceCatalog searches the OPDS catalog for a device +func (h *OPDSHandler) SearchDeviceCatalog(c echo.Context) error { + deviceID := c.Param("deviceId") + query := c.QueryParam("q") + + if query == "" { + return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Missing search query")) + } + + // Get base URLs + baseURL, opdsBaseURL, err := h.getBaseURLs(c) + if err != nil { + return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get config")) + } + + // Parse device ID + deviceUUID, err := uuid.Parse(deviceID) + if err != nil { + return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid device ID")) + } + + // Verify device exists + device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true}) + if err != nil { + return c.XML(http.StatusNotFound, opds.NewErrorFeed("Device not found")) + } + + // Get user's visible libraries + userID := device.UserID.Bytes + + _, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}) + if err != nil { + return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries")) + } + + // Search media items + allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + SearchPattern: pgtype.Text{String: "%" + query + "%", Valid: true}, + Offset: pgtype.Int4{Int32: 0, Valid: true}, + Limit: pgtype.Int4{Int32: 1000, Valid: true}, + }) + if err != nil { + allItems = []database.SearchMediaItemsRow{} + } + + // Create OPDS feed + feed := opds.NewFeed( + fmt.Sprintf("urn:uuid:%s", deviceID), + fmt.Sprintf("Search: %s", query), + ) + + // Add feed links + catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID) + feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start") + + searchURL := fmt.Sprintf("%s/opds/devices/%s/search?q=%s", opdsBaseURL, deviceID, query) + feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self") + + // Add entries (same as catalog) + userUUID := uuid.UUID(userID) + for _, item := range allItems { + bookUUID := uuid.UUID(item.ID.Bytes).String() + title := item.Title + author := "" + if item.Author.Valid { + author = item.Author.String + } + + updated := item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z") + + entry := opds.NewEntry( + fmt.Sprintf("urn:uuid:%s", bookUUID), + title, + author, + updated, + ) + + if item.Description.Valid { + entry.SetSummary(item.Description.String) + } + + downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID) + entry.AddAcquisitionLink(downloadURL, "application/epub+zip") + + kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL) + entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip") + + entry.SetIdentifier(bookUUID) + + if item.FileSha256.Valid { + entry.AddMetadata("bookmann:sha256", item.FileSha256.String) + } + + collections, err := h.db.GetCollectionsForBook(c.Request().Context(), pgtype.UUID{Bytes: item.ID.Bytes, Valid: true}) + if err == nil { + collectionScheme := fmt.Sprintf("%s/collections", baseURL) + for _, col := range collections { + if col.UserID.Valid && uuid.UUID(col.UserID.Bytes) == userUUID { + entry.AddCategory(collectionScheme, col.Name) + } + } + } + + feed.AddEntry(entry) + } + + xmlString, err := feed.GenerateXMLString() + if err != nil { + return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed")) + } + + c.Response().Header().Set("Content-Type", "application/atom+xml;profile=opds-catalog;kind=acquisition") + return c.String(http.StatusOK, xmlString) +} + +// DownloadBook downloads a book with optional format conversion +func (h *OPDSHandler) DownloadBook(c echo.Context) error { + deviceID := c.Param("deviceId") + bookID := c.Param("bookId") + format := c.QueryParam("format") // epub, kepub, pdf, cbz + + // Default to epub + if format == "" { + format = "epub" + } + + // Parse IDs + deviceUUID, err := uuid.Parse(deviceID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"}) + } + + bookUUID, err := uuid.Parse(bookID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"}) + } + + // Verify device exists + device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true}) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"}) + } + + // Get media item + mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: bookUUID, Valid: true}) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"}) + } + + // Get user's visible libraries + userID := device.UserID.Bytes + libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"}) + } + + // Check if book is in visible library + visible := false + for _, lib := range libraries { + if uuid.UUID(lib.ID.Bytes) == uuid.UUID(mediaItem.LibraryID.Bytes) { + visible = true + break + } + } + + if !visible { + return c.JSON(http.StatusForbidden, map[string]string{"error": "book not accessible"}) + } + + // Get format information + filePath := "" + var fileSha256 string + mimeType := "" + + if format == "kepub" { + // Check for pre-converted KEPUB + kepubFormat, err := h.db.GetMediaItemFormatByType(c.Request().Context(), database.GetMediaItemFormatByTypeParams{ + MediaItemID: pgtype.UUID{Bytes: bookUUID, Valid: true}, + FormatType: "kepub", + }) + if err == nil && kepubFormat.FilePath.Valid { + filePath = kepubFormat.FilePath.String + if kepubFormat.FileSha256.Valid { + fileSha256 = kepubFormat.FileSha256.String + } + if kepubFormat.MimeType.Valid { + mimeType = kepubFormat.MimeType.String + } + } else { + // Fall back to EPUB (no on-the-fly conversion for now) + filePath = mediaItem.FilePath + if mediaItem.MimeType.Valid { + mimeType = mediaItem.MimeType.String + } + } + } else if format == "pdf" { + // Check for PDF format + pdfFormat, err := h.db.GetMediaItemFormatByType(c.Request().Context(), database.GetMediaItemFormatByTypeParams{ + MediaItemID: pgtype.UUID{Bytes: bookUUID, Valid: true}, + FormatType: "pdf", + }) + if err == nil && pdfFormat.FilePath.Valid { + filePath = pdfFormat.FilePath.String + if pdfFormat.FileSha256.Valid { + fileSha256 = pdfFormat.FileSha256.String + } + if pdfFormat.MimeType.Valid { + mimeType = pdfFormat.MimeType.String + } + } else { + return c.JSON(http.StatusNotFound, map[string]string{"error": "pdf format not available"}) + } + } else { + // Default: EPUB + filePath = mediaItem.FilePath + if mediaItem.MimeType.Valid { + mimeType = mediaItem.MimeType.String + } + } + + // Check if file exists + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"}) + } + + // Open file + file, err := os.Open(filePath) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to open file"}) + } + defer file.Close() + + // Get file info + fileInfo, err := file.Stat() + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get file info"}) + } + + // Set headers + filename := filepath.Base(filePath) + c.Response().Header().Set("Content-Type", mimeType) + c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + c.Response().Header().Set("X-Bookmann-UUID", bookUUID.String()) + + if fileSha256 != "" { + c.Response().Header().Set("X-Bookmann-SHA256", fileSha256) + } + if format == "kepub" && fileSha256 != "" { + c.Response().Header().Set("X-Bookmann-KEPUB-SHA256", fileSha256) + } + + // Stream file + http.ServeContent(c.Response(), c.Request(), filename, fileInfo.ModTime(), file) + return nil +} + +// GetCoverImage serves a book's cover image +func (h *OPDSHandler) GetCoverImage(c echo.Context) error { + deviceID := c.Param("deviceId") + bookID := c.Param("bookId") + + // Parse IDs + deviceUUID, err := uuid.Parse(deviceID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"}) + } + + bookUUID, err := uuid.Parse(bookID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"}) + } + + // Verify device exists + device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true}) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"}) + } + + // Get media item + mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: bookUUID, Valid: true}) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"}) + } + + // Get user's visible libraries + userID := device.UserID.Bytes + libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"}) + } + + // Check if book is in visible library + visible := false + for _, lib := range libraries { + if uuid.UUID(lib.ID.Bytes) == uuid.UUID(mediaItem.LibraryID.Bytes) { + visible = true + break + } + } + + if !visible { + return c.JSON(http.StatusForbidden, map[string]string{"error": "book not accessible"}) + } + + // Check if cover exists + if !mediaItem.CoverImagePath.Valid || mediaItem.CoverImagePath.String == "" { + return c.NoContent(http.StatusNoContent) + } + + coverPath := mediaItem.CoverImagePath.String + + // Check if file exists + if _, err := os.Stat(coverPath); os.IsNotExist(err) { + return c.NoContent(http.StatusNoContent) + } + + // Open file + file, err := os.Open(coverPath) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to open cover"}) + } + defer file.Close() + + // Get file info + fileInfo, err := file.Stat() + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get file info"}) + } + + // Determine content type + ext := strings.ToLower(filepath.Ext(coverPath)) + contentType := "image/jpeg" + if ext == ".png" { + contentType = "image/png" + } else if ext == ".webp" { + contentType = "image/webp" + } + + // Set cache headers + c.Response().Header().Set("Content-Type", contentType) + c.Response().Header().Set("Cache-Control", "public, max-age=31536000") + + // Stream image + http.ServeContent(c.Response(), c.Request(), "", fileInfo.ModTime(), file) + return nil +} + +// GetDeviceNavigation returns the OPDS navigation feed for a device +func (h *OPDSHandler) GetDeviceNavigation(c echo.Context) error { + deviceID := c.Param("deviceId") + + // Get base URLs + _, opdsBaseURL, err := h.getBaseURLs(c) + if err != nil { + return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get config")) + } + + // Parse device ID + deviceUUID, err := uuid.Parse(deviceID) + if err != nil { + return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid device ID")) + } + + // Verify device exists + _, err = h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true}) + if err != nil { + return c.XML(http.StatusNotFound, opds.NewErrorFeed("Device not found")) + } + + // Create OPDS navigation feed + feed := opds.NewFeed( + fmt.Sprintf("urn:uuid:%s", deviceID), + "Bookmann Library Navigation", + ) + + // Add feed links + catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID) + feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start") + feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self") + + // Add navigation entry for catalog + catalogEntry := opds.NewEntry( + fmt.Sprintf("urn:uuid:%s-catalog", deviceID), + "All Books", + "", + feed.Updated, + ) + catalogEntry.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "subsection") + catalogEntry.SetSummary("Browse all books in the library") + feed.AddEntry(catalogEntry) + + // Generate XML + xmlString, err := feed.GenerateXMLString() + if err != nil { + return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed")) + } + + c.Response().Header().Set("Content-Type", "application/atom+xml;profile=opds-catalog;kind=navigation") + return c.String(http.StatusOK, xmlString) +} + +// ListFormats lists available formats for a book +func (h *OPDSHandler) ListFormats(c echo.Context) error { + deviceID := c.Param("deviceId") + bookID := c.Param("bookId") + + // Parse IDs + deviceUUID, err := uuid.Parse(deviceID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"}) + } + + bookUUID, err := uuid.Parse(bookID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"}) + } + + // Verify device exists + device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true}) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"}) + } + + // Get media item + mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: bookUUID, Valid: true}) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"}) + } + + // Get user's visible libraries + userID := device.UserID.Bytes + libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"}) + } + + // Check if book is in visible library + visible := false + for _, lib := range libraries { + if uuid.UUID(lib.ID.Bytes) == uuid.UUID(mediaItem.LibraryID.Bytes) { + visible = true + break + } + } + + if !visible { + return c.JSON(http.StatusForbidden, map[string]string{"error": "book not accessible"}) + } + + // Get all formats + formats, err := h.db.GetMediaItemFormats(c.Request().Context(), pgtype.UUID{Bytes: bookUUID, Valid: true}) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get formats"}) + } + + type FormatInfo struct { + FormatType string `json:"format_type"` + FilePath string `json:"file_path,omitempty"` + FileSha256 string `json:"file_sha256,omitempty"` + FileSize int64 `json:"file_size_bytes,omitempty"` + MimeType string `json:"mime_type,omitempty"` + Available bool `json:"available"` + } + + formatList := []FormatInfo{} + + // Add EPUB format (always available if media item exists) + fileSize := int64(0) + if mediaItem.FileSize.Valid { + fileSize = mediaItem.FileSize.Int64 + } + + formatList = append(formatList, FormatInfo{ + FormatType: "epub", + FilePath: mediaItem.FilePath, + FileSha256: func() string { + if mediaItem.FileSha256.Valid { + return mediaItem.FileSha256.String + } else { + return "" + } + }(), + FileSize: fileSize, + MimeType: func() string { + if mediaItem.MimeType.Valid { + return mediaItem.MimeType.String + } else { + return "application/epub+zip" + } + }(), + Available: true, + }) + + // Add other formats + for _, format := range formats { + fileSize := int64(0) + if format.FileSizeBytes.Valid { + fileSize = format.FileSizeBytes.Int64 + } + + formatList = append(formatList, FormatInfo{ + FormatType: format.FormatType, + FilePath: func() string { + if format.FilePath.Valid { + return format.FilePath.String + } else { + return "" + } + }(), + FileSha256: func() string { + if format.FileSha256.Valid { + return format.FileSha256.String + } else { + return "" + } + }(), + FileSize: fileSize, + MimeType: func() string { + if format.MimeType.Valid { + return format.MimeType.String + } else { + return "" + } + }(), + Available: format.FilePath.Valid, + }) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "media_item_id": bookUUID.String(), + "formats": formatList, + }) +} + +// RegisterOPDS registers a device for OPDS access +func (h *OPDSHandler) RegisterOPDS(c echo.Context) error { + deviceID := c.Param("deviceId") + + // Parse device ID + deviceUUID, err := uuid.Parse(deviceID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"}) + } + + // Verify device exists + _, err = h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true}) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"}) + } + + // Get base URLs + _, opdsBaseURL, err := h.getBaseURLs(c) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get config"}) + } + + // Generate OPDS token + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + tokenBytes = []byte(deviceID + "-" + uuid.New().String()) + } + token := hex.EncodeToString(tokenBytes)[:64] + + // Create OPDS token record (valid for 1 year) + expiresAt := time.Now().AddDate(1, 0, 0) + _, err = h.db.CreateOpdsToken(c.Request().Context(), database.CreateOpdsTokenParams{ + DeviceID: pgtype.UUID{Bytes: deviceUUID, Valid: true}, + Token: token, + TokenType: pgtype.Text{String: "device", Valid: true}, + ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true}, + }) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create OPDS token"}) + } + + catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID) + + return c.JSON(http.StatusOK, map[string]interface{}{ + "opds_token": map[string]interface{}{ + "token": token, + "token_type": "device", + "expires_at": expiresAt.Format(time.RFC3339), + }, + "opds_catalog_url": catalogURL, + "refresh_interval": 3600, + }) +} diff --git a/internal/handlers/sidecar.go b/internal/handlers/sidecar.go new file mode 100644 index 0000000..05cb571 --- /dev/null +++ b/internal/handlers/sidecar.go @@ -0,0 +1,423 @@ +package handlers + +import ( + "bookmann/internal/database" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" +) + +type SidecarHandler struct { + db *database.Queries +} + +func NewSidecarHandler(db *database.Queries) *SidecarHandler { + return &SidecarHandler{db: db} +} + +type SidecarConfig struct { + Version string `json:"version"` + Bookmann SidecarBookmannConfig `json:"bookmann"` + Books map[string]SidecarBook `json:"books"` + Collections []SidecarCollection `json:"collections"` + OPDSEnabled bool `json:"opds_enabled"` + SidecarEnabled bool `json:"sidecar_enabled"` + LastUpdated string `json:"last_updated"` +} + +type SidecarBookmannConfig struct { + OPDSCatalog string `json:"opds_catalog"` + SyncAPI string `json:"sync_api"` + OPDSBaseURL string `json:"opds_base_url"` + APIBaseURL string `json:"api_base_url"` + DeviceID string `json:"device_id"` + DeviceToken string `json:"device_token,omitempty"` +} + +type SidecarBook struct { + BookmannUUID string `json:"bookmann_uuid"` + Title string `json:"title"` + Author string `json:"author"` + AvailableFormats []string `json:"available_formats"` + SHA256 string `json:"sha256,omitempty"` + FilePath string `json:"file_path,omitempty"` +} + +type SidecarCollection struct { + Name string `json:"name"` + ShelfMapping string `json:"shelf_mapping,omitempty"` + BookIDs []string `json:"book_ids"` +} + +// GetSidecarConfig generates and returns sidecar configuration for a device +// GET /api/devices/:device_id/sidecar +func (h *SidecarHandler) GetSidecarConfig(c echo.Context) error { + deviceID, err := uuid.Parse(c.Param("device_id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "invalid device ID", + }) + } + + ctx := c.Request().Context() + pgDeviceID := pgtype.UUID{Bytes: deviceID, Valid: true} + + // Get device info + device, err := h.db.GetDevice(ctx, pgDeviceID) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{ + "error": "device not found", + }) + } + + // Get user info + userID := device.UserID.Bytes + pgUserID := pgtype.UUID{Bytes: userID, Valid: true} + + // Get system config + opdsBaseURL, _ := h.db.GetSystemConfig(ctx, "opds_base_url") + apiBaseURL, _ := h.db.GetSystemConfig(ctx, "api_base_url") + + // Generate URLs + opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL.Value, deviceID.String()) + syncAPIURL := fmt.Sprintf("%s/sync/kobo", apiBaseURL.Value) + + // Get user's visible libraries with media items + mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "failed to fetch media items", + }) + } + + // Build books map (keyed by SHA256, fallback to UUID) + books := make(map[string]SidecarBook) + for _, item := range mediaItems { + bookUUID := uuid.UUID(item.ID.Bytes).String() + + // Use SHA256 as key if available, otherwise use UUID + key := bookUUID + if item.FileSha256.Valid && item.FileSha256.String != "" { + key = item.FileSha256.String + } + + availableFormats := []string{"epub"} + if item.MimeType.Valid { + if item.MimeType.String == "application/epub+zip" || item.MimeType.String == "application/octet-stream" { + availableFormats = append(availableFormats, "kepub") + } + } + + author := "" + if item.Author.Valid { + author = item.Author.String + } + + books[key] = SidecarBook{ + BookmannUUID: bookUUID, + Title: item.Title, + Author: author, + AvailableFormats: availableFormats, + SHA256: item.FileSha256.String, + FilePath: item.FilePath, + } + } + + // Get collections + collections, err := h.db.GetCollectionsByUser(ctx, pgUserID) + if err != nil { + // Non-fatal error, continue with empty collections + collections = []database.Collections{} + } + + // Build collections array + sidecarCollections := []SidecarCollection{} + for _, collection := range collections { + // Get collection items + collectionItems, err := h.db.GetCollectionItems(ctx, collection.ID) + if err != nil { + continue + } + + bookIDs := make([]string, len(collectionItems)) + for i, item := range collectionItems { + bookIDs[i] = uuid.UUID(item.MediaItemID.Bytes).String() + } + + // Check for device shelf mapping + shelfMapping := collection.Name + mapping, err := h.db.GetDeviceShelfMapping(ctx, database.GetDeviceShelfMappingParams{ + DeviceID: pgDeviceID, + CollectionID: pgtype.UUID{Bytes: collection.ID.Bytes, Valid: true}, + }) + if err == nil && mapping.DeviceShelfName.Valid { + shelfMapping = mapping.DeviceShelfName.String + } + + sidecarCollections = append(sidecarCollections, SidecarCollection{ + Name: collection.Name, + ShelfMapping: shelfMapping, + BookIDs: bookIDs, + }) + } + + // Build sidecar config + config := SidecarConfig{ + Version: "1.0", + Bookmann: SidecarBookmannConfig{ + OPDSCatalog: opdsCatalogURL, + SyncAPI: syncAPIURL, + OPDSBaseURL: opdsBaseURL.Value, + APIBaseURL: apiBaseURL.Value, + DeviceID: deviceID.String(), + DeviceToken: device.AuthToken, + }, + Books: books, + Collections: sidecarCollections, + OPDSEnabled: true, + SidecarEnabled: true, + LastUpdated: time.Now().Format(time.RFC3339), + } + + return c.JSON(http.StatusOK, config) +} + +// DownloadSidecarConfig generates a .bookmann.json file for device setup +// GET /api/devices/:device_id/sidecar/download +func (h *SidecarHandler) DownloadSidecarConfig(c echo.Context) error { + deviceID, err := uuid.Parse(c.Param("device_id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "invalid device ID", + }) + } + + ctx := c.Request().Context() + pgDeviceID := pgtype.UUID{Bytes: deviceID, Valid: true} + + // Get device info to get device name + device, err := h.db.GetDevice(ctx, pgDeviceID) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{ + "error": "device not found", + }) + } + + // Generate sidecar config + var sidecarConfig SidecarConfig + + // Reuse GetSidecarConfig logic by building config inline + userID := device.UserID.Bytes + pgUserID := pgtype.UUID{Bytes: userID, Valid: true} + + // Get system config + opdsBaseURL, _ := h.db.GetSystemConfig(ctx, "opds_base_url") + apiBaseURL, _ := h.db.GetSystemConfig(ctx, "api_base_url") + + // Generate URLs + opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL.Value, deviceID.String()) + syncAPIURL := fmt.Sprintf("%s/sync/kobo", apiBaseURL.Value) + + // Get user's visible libraries with media items + mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "failed to fetch media items", + }) + } + + // Build books map + books := make(map[string]SidecarBook) + for _, item := range mediaItems { + bookUUID := uuid.UUID(item.ID.Bytes).String() + + key := bookUUID + if item.FileSha256.Valid && item.FileSha256.String != "" { + key = item.FileSha256.String + } + + availableFormats := []string{"epub"} + if item.MimeType.Valid { + if item.MimeType.String == "application/epub+zip" || item.MimeType.String == "application/octet-stream" { + availableFormats = append(availableFormats, "kepub") + } + } + + author := "" + if item.Author.Valid { + author = item.Author.String + } + + books[key] = SidecarBook{ + BookmannUUID: bookUUID, + Title: item.Title, + Author: author, + AvailableFormats: availableFormats, + SHA256: item.FileSha256.String, + FilePath: item.FilePath, + } + } + + // Get collections + collections, err := h.db.GetCollectionsByUser(ctx, pgUserID) + if err != nil { + collections = []database.Collections{} + } + + // Build collections array + sidecarCollections := []SidecarCollection{} + for _, collection := range collections { + collectionItems, err := h.db.GetCollectionItems(ctx, collection.ID) + if err != nil { + continue + } + + bookIDs := make([]string, len(collectionItems)) + for i, item := range collectionItems { + bookIDs[i] = uuid.UUID(item.MediaItemID.Bytes).String() + } + + shelfMapping := collection.Name + mapping, err := h.db.GetDeviceShelfMapping(ctx, database.GetDeviceShelfMappingParams{ + DeviceID: pgDeviceID, + CollectionID: pgtype.UUID{Bytes: collection.ID.Bytes, Valid: true}, + }) + if err == nil && mapping.DeviceShelfName.Valid { + shelfMapping = mapping.DeviceShelfName.String + } + + sidecarCollections = append(sidecarCollections, SidecarCollection{ + Name: collection.Name, + ShelfMapping: shelfMapping, + BookIDs: bookIDs, + }) + } + + sidecarConfig = SidecarConfig{ + Version: "1.0", + Bookmann: SidecarBookmannConfig{ + OPDSCatalog: opdsCatalogURL, + SyncAPI: syncAPIURL, + OPDSBaseURL: opdsBaseURL.Value, + APIBaseURL: apiBaseURL.Value, + DeviceID: deviceID.String(), + DeviceToken: device.AuthToken, + }, + Books: books, + Collections: sidecarCollections, + OPDSEnabled: true, + SidecarEnabled: true, + LastUpdated: time.Now().Format(time.RFC3339), + } + + // Marshal to JSON with pretty formatting + configJSON, err := json.MarshalIndent(sidecarConfig, "", " ") + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "failed to generate config", + }) + } + + // Set headers for file download + filename := fmt.Sprintf("%s.bookmann.json", sanitizeFilename(device.DeviceName)) + c.Response().Header().Set("Content-Type", "application/json") + c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + + return c.JSONBlob(http.StatusOK, configJSON) +} + +// GetSystemConfiguration returns system-wide configuration +// GET /api/system/config +func (h *SidecarHandler) GetSystemConfiguration(c echo.Context) error { + ctx := c.Request().Context() + + // Get all system config + configs, err := h.db.GetAllSystemConfig(ctx) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "failed to fetch system config", + }) + } + + // Build config map + result := make(map[string]string) + for _, config := range configs { + result[config.Key] = config.Value + } + + return c.JSON(http.StatusOK, result) +} + +// UpdateSystemConfiguration updates system-wide configuration +// PUT /api/system/config +func (h *SidecarHandler) UpdateSystemConfiguration(c echo.Context) error { + user := c.Get("user") + if user == nil { + return c.JSON(http.StatusUnauthorized, map[string]string{ + "error": "unauthorized", + }) + } + + userInfo := user.(database.Users) + if userInfo.Role != "admin" { + return c.JSON(http.StatusForbidden, map[string]string{ + "error": "admin access required", + }) + } + + var req map[string]string + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "invalid request format", + }) + } + + ctx := c.Request().Context() + pgUserID := pgtype.UUID{Bytes: userInfo.ID.Bytes, Valid: true} + + // Update each config value + for key, value := range req { + _, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{ + Key: key, + Value: value, + UpdatedBy: pgUserID, + }) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": fmt.Sprintf("failed to update config key: %s", key), + }) + } + } + + return c.JSON(http.StatusOK, map[string]string{ + "status": "success", + "message": "System configuration updated", + }) +} + +func sanitizeFilename(name string) string { + // Simple sanitization - replace problematic characters + sanitized := name + for _, ch := range []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|"} { + sanitized = sanitizeAll(sanitized, ch, "_") + } + return sanitized +} + +func sanitizeAll(s string, old string, new string) string { + result := "" + for _, ch := range s { + c := string(ch) + if c == old { + result += new + } else { + result += c + } + } + return result +} diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go new file mode 100644 index 0000000..18ea76c --- /dev/null +++ b/internal/handlers/sync.go @@ -0,0 +1,154 @@ +package handlers + +import ( + "bookmann/internal/database" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" +) + +type SyncHandler struct { + db *database.Queries +} + +func NewSyncHandler(db *database.Queries) *SyncHandler { + return &SyncHandler{db: db} +} + +type UnlinkedBookResponse struct { + Unlinked []UnlinkedBookItem `json:"unlinked"` + Total int `json:"total"` +} + +type UnlinkedBookItem struct { + UnlinkedBookID string `json:"unlinked_book_id"` + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + DeviceType string `json:"device_type"` + ContentId string `json:"content_id"` + FilePath string `json:"file_path,omitempty"` + Title string `json:"title,omitempty"` + Author string `json:"author,omitempty"` + ConfidenceScore float64 `json:"confidence_score"` + LastSeenAt time.Time `json:"last_seen_at"` + CreatedAt time.Time `json:"created_at"` +} + +type LinkBookRequest struct { + UnlinkedBookID string `json:"unlinked_book_id"` + MediaItemID string `json:"media_item_id"` + ConfidenceScore float64 `json:"confidence_score"` +} + +type LinkBookResponse struct { + Status string `json:"status"` + UnlinkedBookID string `json:"unlinked_book_id"` + MediaItemID string `json:"media_item_id"` + Message string `json:"message"` +} + +// GetUnlinkedBooks returns all unlinked books for a user's devices +// GET /api/sync/unlinked-books +func (h *SyncHandler) GetUnlinkedBooks(c echo.Context) error { + // Get user from context (assuming auth middleware sets this) + user := c.Get("user") + if user == nil { + return c.JSON(http.StatusUnauthorized, map[string]string{ + "error": "unauthorized", + }) + } + + userID := user.(database.Users).ID + + // Get all devices for this user + devices, err := h.db.ListDevicesByUser(c.Request().Context(), pgtype.UUID{Bytes: userID.Bytes, Valid: true}) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "failed to fetch devices", + }) + } + + var allUnlinkedBooks []UnlinkedBookItem + + // For each device, get unlinked books + for _, device := range devices { + unlinkedBooks, err := h.db.GetUnlinkedBooksByDevice(c.Request().Context(), pgtype.UUID{Bytes: device.ID.Bytes, Valid: true}) + if err != nil { + continue + } + + for _, ub := range unlinkedBooks { + allUnlinkedBooks = append(allUnlinkedBooks, UnlinkedBookItem{ + UnlinkedBookID: uuid.UUID(ub.ID.Bytes).String(), + DeviceID: uuid.UUID(ub.DeviceID.Bytes).String(), + DeviceName: ub.DeviceName, + DeviceType: ub.DeviceType, + ContentId: ub.ContentID, + FilePath: ub.FilePath.String, + Title: ub.Title.String, + Author: ub.Author.String, + ConfidenceScore: ub.ConfidenceScore.Float64, + LastSeenAt: ub.LastSeenAt.Time, + CreatedAt: ub.CreatedAt.Time, + }) + } + } + + return c.JSON(http.StatusOK, UnlinkedBookResponse{ + Unlinked: allUnlinkedBooks, + Total: len(allUnlinkedBooks), + }) +} + +// LinkBook manually links an unlinked book to a media item +// POST /api/sync/link-book +func (h *SyncHandler) LinkBook(c echo.Context) error { + var req LinkBookRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "invalid request format", + }) + } + + // Parse UUIDs + unlinkedBookID, err := uuid.Parse(req.UnlinkedBookID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "invalid unlinked_book_id", + }) + } + + mediaItemID, err := uuid.Parse(req.MediaItemID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "invalid media_item_id", + }) + } + + // Get the unlinked books to find the one we're looking for + // We need to use GetAllUnlinkedBooks or create a new query + // For now, let's link directly using LinkUnlinkedBook + _, err = h.db.LinkUnlinkedBook(c.Request().Context(), database.LinkUnlinkedBookParams{ + ID: pgtype.UUID{Bytes: unlinkedBookID, Valid: true}, + MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, + ConfidenceScore: pgtype.Float8{Float64: req.ConfidenceScore, Valid: true}, + }) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "failed to link book", + }) + } + + // Note: We can't create a device catalog entry without knowing the device_id and content_id + // In a real implementation, we'd first fetch the unlinked book, then create the catalog entry + + return c.JSON(http.StatusOK, LinkBookResponse{ + Status: "linked", + UnlinkedBookID: req.UnlinkedBookID, + MediaItemID: req.MediaItemID, + Message: "Book successfully linked", + }) +}