Files
bookhoard/internal/handlers/media.go
T
john-okeefe 82ff6356b5 feat(collections,media): add bulk operations for books and collections
Collections:
- HandleBulkAddBooks: add multiple books to multiple collections

Media:
- HandleBulkDelete: delete multiple media items
- HandleBulkUpdate: bulk update book metadata (tags, status)
- Individual result tracking for each operation
- WebSocket broadcasts for collection updates
2026-02-01 12:15:47 -05:00

457 lines
13 KiB
Go

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