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, }) } // Bulk Operations // POST /api/books/bulk-delete // Bulk delete books func (h *MediaHandler) HandleBulkDelete(c echo.Context) error { var req struct { BookIDs []string `json:"book_ids" validate:"required"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) } if len(req.BookIDs) == 0 { return c.JSON(http.StatusBadRequest, map[string]string{"error": "book_ids required"}) } results := make([]map[string]interface{}, 0) successCount := 0 failedCount := 0 for _, bookIDStr := range req.BookIDs { bookID, err := uuid.Parse(bookIDStr) if err != nil { results = append(results, map[string]interface{}{ "book_id": bookIDStr, "status": "error", "error": "Invalid UUID", }) failedCount++ continue } bookUUID := pgtype.UUID{Bytes: bookID, Valid: true} book, err := h.db.GetMediaItem(c.Request().Context(), bookUUID) if err != nil { results = append(results, map[string]interface{}{ "book_id": bookIDStr, "status": "error", "error": "Book not found", }) failedCount++ continue } err = h.db.DeleteMediaItem(c.Request().Context(), bookUUID) if err != nil { results = append(results, map[string]interface{}{ "book_id": bookIDStr, "status": "error", "error": err.Error(), }) failedCount++ continue } if book.FilePath != "" { os.Remove(book.FilePath) } results = append(results, map[string]interface{}{ "book_id": bookIDStr, "status": "success", }) successCount++ } return c.JSON(http.StatusOK, map[string]interface{}{ "results": results, "total": len(req.BookIDs), "success": successCount, "failed": failedCount, }) } // POST /api/books/bulk-update // Bulk update book metadata func (h *MediaHandler) HandleBulkUpdate(c echo.Context) error { var req struct { Updates []struct { BookID string `json:"book_id" validate:"required"` Updates struct { Title *string `json:"title,omitempty"` Author *string `json:"author,omitempty"` Genre *string `json:"genre,omitempty"` Language *string `json:"language,omitempty"` Tags *string `json:"tags,omitempty"` } `json:"updates"` } `json:"updates" validate:"required"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) } if len(req.Updates) == 0 { return c.JSON(http.StatusBadRequest, map[string]string{"error": "updates required"}) } results := make([]map[string]interface{}, 0) successCount := 0 failedCount := 0 for _, update := range req.Updates { bookID, err := uuid.Parse(update.BookID) if err != nil { results = append(results, map[string]interface{}{ "book_id": update.BookID, "status": "error", "error": "Invalid UUID", }) failedCount++ continue } bookUUID := pgtype.UUID{Bytes: bookID, Valid: true} existingBook, err := h.db.GetMediaItem(c.Request().Context(), bookUUID) if err != nil { results = append(results, map[string]interface{}{ "book_id": update.BookID, "status": "error", "error": "Book not found", }) failedCount++ continue } updateParams := database.UpdateMediaItemParams{ ID: bookUUID, Title: existingBook.Title, Author: existingBook.Author, Genre: existingBook.Genre, Language: existingBook.Language, Tags: existingBook.Tags, Description: existingBook.Description, Publisher: existingBook.Publisher, CopyrightYear: existingBook.CopyrightYear, Isbn: existingBook.Isbn, Series: existingBook.Series, SeriesNumber: existingBook.SeriesNumber, Asin: existingBook.Asin, DatePublished: existingBook.DatePublished, Contributors: existingBook.Contributors, Edition: existingBook.Edition, PageCount: existingBook.PageCount, GoodreadsID: existingBook.GoodreadsID, OpenlibraryID: existingBook.OpenlibraryID, CoverImagePath: existingBook.CoverImagePath, } if update.Updates.Title != nil { updateParams.Title = *update.Updates.Title } if update.Updates.Author != nil { updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true} } if update.Updates.Genre != nil { updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true} } if update.Updates.Language != nil { updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true} } if update.Updates.Tags != nil { updateParams.Tags = pgtype.Text{String: *update.Updates.Tags, Valid: true} } _, err = h.db.UpdateMediaItem(c.Request().Context(), updateParams) if err != nil { results = append(results, map[string]interface{}{ "book_id": update.BookID, "status": "error", "error": err.Error(), }) failedCount++ continue } results = append(results, map[string]interface{}{ "book_id": update.BookID, "status": "success", }) successCount++ } return c.JSON(http.StatusOK, map[string]interface{}{ "results": results, "total": len(req.Updates), "success": successCount, "failed": failedCount, }) }