Files
bookhoard/internal/handlers/collections.go
T
john-okeefe 209e9f2a3c feat: implement relative path storage and URL resolution for media files
- Add libraryService dependency to CollectionHandler and OPDSHandler for centralized path resolution
- Create internal/utils/mediaurl.go with ResolveMediaURL() function as single source of truth
- Update GetMediaItem and ListMediaItems handlers to return resolved URLs in API responses
- Update collection handlers (GetCollection, TestRules, PreviewCollection) to use resolved cover URLs
- Update progress handler (GetAllProgress) to use resolved cover URLs
- Add library_id to GetCollectionItems SQL query to enable URL resolution
- Refactor media scanner to store relative paths instead of absolute filesystem paths
- Add ResolveMediaPath() to LibraryService for resolving relative paths to absolute paths
- Add ServeFile endpoint at /uploads/library-:id/* for authenticated file serving
- Add MimeTypes map to library_service.go for consistent MIME type handling
- Update DownloadBook handler to use resolved filesystem paths
- Add getRelativePath() helper to MediaScanner for converting absolute to relative paths
- Use strings.EqualFold for case-insensitive path comparisons in zip extraction

This change enables the application to work with relative paths stored in the
database, making it portable across different server environments while
maintaining backward compatibility with existing absolute paths.
2026-02-27 16:51:44 -05:00

973 lines
30 KiB
Go

package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
wsync "bookhoard/internal/sync"
"bookhoard/internal/utils"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
)
type CollectionHandler struct {
db *database.Queries
collectionService *services.CollectionService
libraryService *services.LibraryService
connManager *wsync.ConnectionManager
}
func NewCollectionHandler(db *database.Queries, libraryService *services.LibraryService, connManager *wsync.ConnectionManager) *CollectionHandler {
return &CollectionHandler{
db: db,
collectionService: services.NewCollectionService(db),
libraryService: libraryService,
connManager: connManager,
}
}
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"`
}
type BookInfo struct {
MediaItemID string `json:"media_item_id"`
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
}
type SectionData struct {
ID string `json:"id"`
IsSystem bool `json:"is_system"`
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
Items []BookInfo `json:"items"`
ViewAllURL string `json:"view_all_url"`
Priority int `json:"priority"`
}
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()})
}
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: utils.ResolveMediaURL(book.LibraryID, 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()})
}
addedCount := 0
var addedBookIDs []string
for _, bookIDStr := range req.BookIDs {
bookID, err := uuid.Parse(bookIDStr)
if err != nil {
continue
}
err = h.collectionService.AddBookToCollection(c.Request().Context(), collectionID, bookID, userUUID)
if err == nil {
addedCount++
addedBookIDs = append(addedBookIDs, bookID.String())
}
}
if addedCount > 0 && h.connManager != nil {
h.connManager.Broadcast(wsync.BroadcastMessage{
Type: "collection_updated",
Timestamp: time.Now().Format(time.RFC3339),
Data: map[string]interface{}{
"collection_id": collectionID.String(),
"action": "books_added",
"book_ids": addedBookIDs,
"count": addedCount,
},
})
}
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)
}
type BulkRemoveBooksRequest struct {
BookIDs []string `json:"book_ids" validate:"required"`
}
func (h *CollectionHandler) BulkRemoveBooks(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 BulkRemoveBooksRequest
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()})
}
removedCount := 0
var removedBookIDs []string
for _, bookIDStr := range req.BookIDs {
bookID, err := uuid.Parse(bookIDStr)
if err != nil {
continue
}
err = h.collectionService.RemoveBookFromCollection(c.Request().Context(), collectionID, bookID)
if err == nil {
removedCount++
removedBookIDs = append(removedBookIDs, bookID.String())
}
}
if removedCount > 0 && h.connManager != nil {
h.connManager.Broadcast(wsync.BroadcastMessage{
Type: "collection_updated",
Timestamp: time.Now().Format(time.RFC3339),
Data: map[string]interface{}{
"collection_id": collectionID.String(),
"action": "books_removed",
"book_ids": removedBookIDs,
"count": removedCount,
},
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"removed": removedCount,
"total": len(req.BookIDs),
})
}
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)
}
func (h *CollectionHandler) GetDeviceMappingsData(c echo.Context, deviceID uuid.UUID) ([]database.GetDeviceShelfMappingsRow, error) {
return h.collectionService.GetDeviceShelfMappings(c.Request().Context(), deviceID)
}
func (h *CollectionHandler) GetUserCollectionsList(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)
}
type TestRulesRequest struct {
Rules []map[string]interface{} `json:"rules" validate:"required"`
}
type BookMatch struct {
MediaItemID string `json:"media_item_id"`
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
MatchReason string `json:"match_reason"`
}
func (h *CollectionHandler) TestRules(c echo.Context) error {
var req TestRulesRequest
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()})
}
mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
Limit: 1000,
Offset: 0,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"})
}
var matches []BookMatch
for _, item := range mediaItems {
matchReason := h.checkRulesAgainstBook(item, req.Rules)
if matchReason != "" {
author := ""
if item.Author.Valid {
author = item.Author.String
}
matches = append(matches, BookMatch{
MediaItemID: uuid.UUID(item.ID.Bytes).String(),
Title: item.Title,
Author: author,
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
MatchReason: matchReason,
})
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"matches": matches,
"total": len(matches),
})
}
func (h *CollectionHandler) checkRulesAgainstBook(item database.ListMediaItemsRow, rules []map[string]interface{}) string {
for _, rule := range rules {
field, _ := rule["field"].(string)
operator, _ := rule["operator"].(string)
value, _ := rule["value"].(string)
if h.evaluateRule(item, field, operator, value) {
return fmt.Sprintf("Matched rule: %s %s %s", field, operator, value)
}
}
return ""
}
func (h *CollectionHandler) evaluateRule(item database.ListMediaItemsRow, field, operator, value string) bool {
var itemValue string
switch field {
case "genre":
if item.Genre.Valid {
itemValue = item.Genre.String
}
case "series":
if item.Series.Valid {
itemValue = item.Series.String
}
case "author":
if item.Author.Valid {
itemValue = item.Author.String
}
case "language":
if item.Language.Valid {
itemValue = item.Language.String
}
case "publisher":
if item.Publisher.Valid {
itemValue = item.Publisher.String
}
case "copyright_year":
if item.CopyrightYear.Valid {
itemValue = fmt.Sprintf("%d", item.CopyrightYear.Int32)
}
case "tags":
if len(item.Tags) > 0 {
itemValue = strings.Join(item.Tags, ", ")
}
}
return h.compareValues(itemValue, operator, value)
}
func (h *CollectionHandler) compareValues(itemValue, operator, ruleValue string) bool {
switch operator {
case "equals":
return itemValue == ruleValue
case "not_equals":
return itemValue != ruleValue
case "contains":
return strings.Contains(strings.ToLower(itemValue), strings.ToLower(ruleValue))
case "not_contains":
return !strings.Contains(strings.ToLower(itemValue), strings.ToLower(ruleValue))
case "starts_with":
return strings.HasPrefix(strings.ToLower(itemValue), strings.ToLower(ruleValue))
case "ends_with":
return strings.HasSuffix(strings.ToLower(itemValue), strings.ToLower(ruleValue))
case "greater_than":
itemNum, err1 := strconv.Atoi(itemValue)
ruleNum, err2 := strconv.Atoi(ruleValue)
if err1 != nil || err2 != nil {
return false
}
return itemNum > ruleNum
case "less_than":
itemNum, err1 := strconv.Atoi(itemValue)
ruleNum, err2 := strconv.Atoi(ruleValue)
if err1 != nil || err2 != nil {
return false
}
return itemNum < ruleNum
}
return false
}
// Bulk Operations
// POST /api/collections/bulk-add-books
// Bulk add books to multiple collections
func (h *CollectionHandler) HandleBulkAddBooks(c echo.Context) error {
user := c.Get("user").(database.Users)
var req struct {
Operations []struct {
CollectionID string `json:"collection_id" validate:"required"`
BookIDs []string `json:"book_ids" validate:"required"`
} `json:"operations" validate:"required"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
if len(req.Operations) == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "operations required"})
}
results := make([]map[string]interface{}, 0)
successCount := 0
failedCount := 0
for _, op := range req.Operations {
collectionID, err := uuid.Parse(op.CollectionID)
if err != nil {
results = append(results, map[string]interface{}{
"collection_id": op.CollectionID,
"status": "error",
"error": "Invalid collection UUID",
})
failedCount++
continue
}
for _, bookIDStr := range op.BookIDs {
bookID, err := uuid.Parse(bookIDStr)
if err != nil {
results = append(results, map[string]interface{}{
"collection_id": op.CollectionID,
"book_id": bookIDStr,
"status": "error",
"error": "Invalid book UUID",
})
failedCount++
continue
}
collectionUUID := pgtype.UUID{Bytes: collectionID, Valid: true}
bookUUID := pgtype.UUID{Bytes: bookID, Valid: true}
_, err = h.db.AddBookToCollection(c.Request().Context(), database.AddBookToCollectionParams{
CollectionID: collectionUUID,
MediaItemID: bookUUID,
AddedByUserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
})
if err != nil {
results = append(results, map[string]interface{}{
"collection_id": op.CollectionID,
"book_id": bookIDStr,
"status": "error",
"error": err.Error(),
})
failedCount++
continue
}
results = append(results, map[string]interface{}{
"collection_id": op.CollectionID,
"book_id": bookIDStr,
"status": "success",
})
successCount++
}
}
if successCount > 0 && h.connManager != nil {
h.connManager.Broadcast(wsync.BroadcastMessage{
Type: "collection_updated",
Timestamp: time.Now().Format(time.RFC3339),
Data: map[string]interface{}{
"action": "books_added_bulk",
"count": successCount,
},
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"results": results,
"total": len(results),
"added": successCount,
"failed": failedCount,
})
}
func (h *CollectionHandler) PreviewCollection(c echo.Context) error {
user := c.Get("user")
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
}
var req struct {
LibraryID string `json:"library_id"`
Rules []services.Rule `json:"rules"`
ManualBookIDs []string `json:"manual_book_ids"`
Limit int `json:"limit"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
libUUID, err := uuid.Parse(req.LibraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
_, err = h.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if req.Limit <= 0 || req.Limit > 100 {
req.Limit = 20
}
allItems, err := h.db.GetLibraryItems(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"})
}
var matchedItems []database.MediaItems
for _, item := range allItems {
listItem := mediaItemsToListMediaItemsRow(item)
evaluations := h.collectionService.EvaluateRules(listItem, req.Rules)
for _, eval := range evaluations {
if eval.Matches {
matchedItems = append(matchedItems, item)
break
}
}
}
for _, bookID := range req.ManualBookIDs {
bookUUID, err := uuid.Parse(bookID)
if err != nil {
continue
}
for _, item := range allItems {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
if itemUUID == bookUUID {
alreadyAdded := false
for _, added := range matchedItems {
addedUUID, _ := uuid.FromBytes(added.ID.Bytes[0:16])
if addedUUID == bookUUID {
alreadyAdded = true
break
}
}
if !alreadyAdded {
matchedItems = append(matchedItems, item)
}
break
}
}
}
if len(matchedItems) > req.Limit {
matchedItems = matchedItems[:req.Limit]
}
bookCards := make([]BookInfo, len(matchedItems))
for i, item := range matchedItems {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards[i] = BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
}
}
return c.JSON(http.StatusOK, map[string]interface{}{"items": bookCards})
}
func mediaItemsToListMediaItemsRow(item database.MediaItems) database.ListMediaItemsRow {
return database.ListMediaItemsRow{
ID: item.ID,
LibraryID: item.LibraryID,
Title: item.Title,
Author: item.Author,
Isbn: item.Isbn,
Description: item.Description,
FilePath: item.FilePath,
FileSize: item.FileSize,
MimeType: item.MimeType,
CoverImagePath: item.CoverImagePath,
Series: item.Series,
SeriesNumber: item.SeriesNumber,
Tags: item.Tags,
Asin: item.Asin,
DatePublished: item.DatePublished,
Publisher: item.Publisher,
Contributors: item.Contributors,
Language: item.Language,
Edition: item.Edition,
PageCount: item.PageCount,
Genre: item.Genre,
CopyrightYear: item.CopyrightYear,
GoodreadsID: item.GoodreadsID,
OpenlibraryID: item.OpenlibraryID,
GoogleBooksID: item.GoogleBooksID,
AddedByAdminID: item.AddedByAdminID,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
FormatGroup: item.FormatGroup,
FormatMimetype: item.FormatMimetype,
IsReflowable: item.IsReflowable,
HasFixedLayout: item.HasFixedLayout,
TotalCharacters: item.TotalCharacters,
ChapterCount: item.ChapterCount,
EntitlementID: item.EntitlementID,
RevisionNumber: item.RevisionNumber,
KoboContentID: item.KoboContentID,
KoboMetadata: item.KoboMetadata,
TagsSearch: item.TagsSearch,
ContributorsSearch: item.ContributorsSearch,
FileSha256: item.FileSha256,
OpfIdentifier: item.OpfIdentifier,
OpfUuid: item.OpfUuid,
HashConfidence: item.HashConfidence,
LibraryName: "",
LibraryTypeName: "",
}
}