diff --git a/internal/handlers/media.go b/internal/handlers/media.go new file mode 100644 index 0000000..df89893 --- /dev/null +++ b/internal/handlers/media.go @@ -0,0 +1,262 @@ +package handlers + +import ( + "bookmann/internal/database" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "strconv" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" +) + +type MediaHandler struct { + db *database.Queries +} + +func NewMediaHandler(db *database.Queries) *MediaHandler { + return &MediaHandler{db: db} +} + +func (h *MediaHandler) DownloadBook(c echo.Context) error { + bookUUID, err := uuid.Parse(c.Param("uuid")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book UUID"}) + } + + pgBookUUID := pgtype.UUID{Bytes: bookUUID, Valid: true} + + mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgBookUUID) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"}) + } + + if _, err := os.Stat(mediaItem.FilePath); os.IsNotExist(err) { + return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found on disk"}) + } + + file, err := os.Open(mediaItem.FilePath) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to open book file"}) + } + defer file.Close() + + mimeType := mediaItem.MimeType.String + if !mediaItem.MimeType.Valid || mimeType == "" { + mimeType = mime.TypeByExtension(filepath.Ext(mediaItem.FilePath)) + } + + c.Response().Header().Set("Content-Type", mimeType) + c.Response().Header().Set("Content-Disposition", "attachment; filename=\""+filepath.Base(mediaItem.FilePath)+"\"") + + if mediaItem.FileSize.Valid && mediaItem.FileSize.Int64 > 0 { + c.Response().Header().Set("Content-Length", strconv.FormatInt(mediaItem.FileSize.Int64, 10)) + } + + _, err = io.Copy(c.Response(), file) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to stream file"}) + } + + return nil +} + +type AddToShelfRequest struct { + MediaItemIDs []string `json:"media_item_ids" validate:"required"` + ShelfName string `json:"shelf_name"` + ShelfPosition int `json:"shelf_position"` +} + +func (h *MediaHandler) AddToShelf(c echo.Context) error { + deviceUUID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"}) + } + + var req AddToShelfRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"}) + } + + if req.ShelfName == "" { + req.ShelfName = "Default" + } + + pgDeviceUUID := pgtype.UUID{Bytes: deviceUUID, Valid: true} + addedCount := 0 + + for _, mediaID := range req.MediaItemIDs { + mediaUUID, err := uuid.Parse(mediaID) + if err != nil { + continue + } + + _, err = h.db.AddBookToKoboShelf(c.Request().Context(), database.AddBookToKoboShelfParams{ + DeviceID: pgDeviceUUID, + MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, + ShelfName: pgtype.Text{String: req.ShelfName, Valid: true}, + ShelfPosition: pgtype.Int4{Int32: int32(req.ShelfPosition), Valid: true}, + }) + + if err == nil { + addedCount++ + req.ShelfPosition++ + } + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "status": "success", + "added_count": addedCount, + "shelf_name": req.ShelfName, + "total_count": len(req.MediaItemIDs), + }) +} + +func (h *MediaHandler) GetShelf(c echo.Context) error { + deviceUUID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"}) + } + + shelfName := c.QueryParam("shelf") + pgDeviceUUID := pgtype.UUID{Bytes: deviceUUID, Valid: true} + + var shelfBooks []database.GetKoboShelfBooksByShelfNameRow + if shelfName != "" { + shelfBooks, err = h.db.GetKoboShelfBooksByShelfName(c.Request().Context(), database.GetKoboShelfBooksByShelfNameParams{ + DeviceID: pgDeviceUUID, + ShelfName: pgtype.Text{String: shelfName, Valid: true}, + }) + } else { + var defaultBooks []database.GetKoboShelfBooksRow + defaultBooks, err = h.db.GetKoboShelfBooks(c.Request().Context(), pgDeviceUUID) + shelfBooks = make([]database.GetKoboShelfBooksByShelfNameRow, len(defaultBooks)) + for i, b := range defaultBooks { + shelfBooks[i] = database.GetKoboShelfBooksByShelfNameRow{ + ID: b.ID, + DeviceID: b.DeviceID, + MediaItemID: b.MediaItemID, + ShelfName: b.ShelfName, + ShelfPosition: b.ShelfPosition, + AddedAt: b.AddedAt, + LastSyncedAt: b.LastSyncedAt, + Title: b.Title, + Author: b.Author, + FilePath: b.FilePath, + MimeType: b.MimeType, + EntitlementID: b.EntitlementID, + KoboContentID: b.KoboContentID, + RevisionNumber: b.RevisionNumber, + } + } + } + + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to fetch shelf"}) + } + + type ShelfBookResponse struct { + ID string `json:"id"` + MediaItemID string `json:"media_item_id"` + Title string `json:"title"` + Author string `json:"author"` + FilePath string `json:"file_path"` + MimeType string `json:"mime_type"` + ShelfName string `json:"shelf_name"` + ShelfPosition int `json:"shelf_position"` + EntitlementID string `json:"entitlement_id,omitempty"` + KoboContentID string `json:"kobo_content_id,omitempty"` + RevisionNumber int `json:"revision_number"` + AddedAt string `json:"added_at"` + } + + response := []ShelfBookResponse{} + for _, book := range shelfBooks { + response = append(response, ShelfBookResponse{ + ID: uuid.UUID(book.ID.Bytes).String(), + MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(), + Title: book.Title, + Author: book.Author.String, + FilePath: book.FilePath, + MimeType: book.MimeType.String, + ShelfName: book.ShelfName.String, + ShelfPosition: int(book.ShelfPosition.Int32), + EntitlementID: book.EntitlementID.String, + KoboContentID: book.KoboContentID.String, + RevisionNumber: int(book.RevisionNumber.Int32), + AddedAt: book.AddedAt.Time.Format("2006-01-02T15:04:05Z07:00"), + }) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "status": "success", + "device_id": deviceUUID.String(), + "shelf_name": shelfName, + "books": response, + "total": len(response), + }) +} + +func (h *MediaHandler) RemoveFromShelf(c echo.Context) error { + deviceUUID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"}) + } + + mediaUUID, err := uuid.Parse(c.QueryParam("media_item_id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item ID"}) + } + + pgDeviceUUID := pgtype.UUID{Bytes: deviceUUID, Valid: true} + pgMediaUUID := pgtype.UUID{Bytes: mediaUUID, Valid: true} + + err = h.db.RemoveBookFromKoboShelf(c.Request().Context(), database.RemoveBookFromKoboShelfParams{ + DeviceID: pgDeviceUUID, + MediaItemID: pgMediaUUID, + }) + + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to remove from shelf"}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "status": "success", + "message": "book removed from shelf", + }) +} + +func (h *MediaHandler) ClearShelf(c echo.Context) error { + deviceUUID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"}) + } + + shelfName := c.QueryParam("shelf") + pgDeviceUUID := pgtype.UUID{Bytes: deviceUUID, Valid: true} + + var result string + if shelfName != "" { + err = h.db.ClearKoboShelfByName(c.Request().Context(), database.ClearKoboShelfByNameParams{ + DeviceID: pgDeviceUUID, + ShelfName: pgtype.Text{String: shelfName, Valid: true}, + }) + result = "shelf cleared" + } else { + err = h.db.ClearKoboShelf(c.Request().Context(), pgDeviceUUID) + result = "all shelves cleared" + } + + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to clear shelf"}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "status": "success", + "message": result, + }) +}