package handlers import ( "bookhoard/internal/database" "bookhoard/internal/services" "net/http" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" ) // Book matching service (added to Handler struct in scanner.go initialization) func (h *Handler) getMatchingService() *services.BookMatchingService { return services.NewBookMatchingService(h.db) } // BulkLinkBooksRequest represents a bulk linking request type BulkLinkBooksRequest struct { Links []struct { UnlinkedBookID uuid.UUID `json:"unlinked_book_id"` MediaItemID uuid.UUID `json:"media_item_id"` ConfidenceScore float64 `json:"confidence_score"` } `json:"links"` } // AutoLinkBooksRequest represents an auto-link request type AutoLinkBooksRequest struct { ConfidenceThreshold float64 `json:"confidence_threshold"` Limit int `json:"limit"` } // Helper function to convert float64 to pgtype.Float8 func toFloat8(f float64) pgtype.Float8 { var result pgtype.Float8 result.Scan(f) return result } // BulkLinkBooks handles POST /api/sync/bulk-link-books func (h *Handler) BulkLinkBooks(c *echo.Context) error { ctx := c.Request().Context() var req BulkLinkBooksRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "Invalid request body", }) } results := make([]map[string]interface{}, 0, len(req.Links)) for _, link := range req.Links { unlinkedBook, err := h.db.GetUnlinkedBookByID(ctx, pgtype.UUID{Bytes: link.UnlinkedBookID, Valid: true}) if err != nil { results = append(results, map[string]interface{}{ "unlinked_book_id": link.UnlinkedBookID.String(), "status": "error", "error": "Unlinked book not found", }) continue } _, err = h.db.CreateDeviceFileAlias(ctx, database.CreateDeviceFileAliasParams{ MediaItemID: pgtype.UUID{Bytes: link.MediaItemID, Valid: true}, DeviceID: unlinkedBook.DeviceID, FilePath: unlinkedBook.FilePath.String, FileSha256: pgtype.Text{String: "", Valid: false}, // SHA256 not stored in unlinked_books ConfidenceScore: toFloat8(link.ConfidenceScore), }) if err != nil { results = append(results, map[string]interface{}{ "unlinked_book_id": link.UnlinkedBookID.String(), "status": "error", "error": err.Error(), }) continue } _, err = h.db.LinkUnlinkedBook(ctx, database.LinkUnlinkedBookParams{ ID: pgtype.UUID{Bytes: link.UnlinkedBookID, Valid: true}, MediaItemID: pgtype.UUID{Bytes: link.MediaItemID, Valid: true}, ConfidenceScore: toFloat8(link.ConfidenceScore), }) if err != nil { results = append(results, map[string]interface{}{ "unlinked_book_id": link.UnlinkedBookID.String(), "status": "warning", "error": "Linked but failed to mark as resolved", }) continue } results = append(results, map[string]interface{}{ "unlinked_book_id": link.UnlinkedBookID.String(), "status": "success", "media_item_id": link.MediaItemID.String(), }) } successfulCount := 0 for _, r := range results { if r["status"] == "success" { successfulCount++ } } return c.JSON(http.StatusOK, map[string]interface{}{ "results": results, "total": len(req.Links), "successful": successfulCount, "failed": len(req.Links) - successfulCount, }) } // AutoLinkBooks handles POST /api/sync/auto-link-books func (h *Handler) AutoLinkBooks(c *echo.Context) error { ctx := c.Request().Context() matchingService := h.getMatchingService() var req AutoLinkBooksRequest if err := c.Bind(&req); err != nil { req.ConfidenceThreshold = 0.8 req.Limit = 50 } if req.ConfidenceThreshold == 0 { req.ConfidenceThreshold = 0.8 } if req.Limit == 0 { req.Limit = 50 } offset := 0 unlinked, err := h.db.ListUnresolvedUnlinkedBooks(ctx, database.ListUnresolvedUnlinkedBooksParams{ Limit: int32(req.Limit), Offset: int32(offset), }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ "error": "Failed to get unlinked books", }) } results := make([]map[string]interface{}, 0) for _, book := range unlinked { queryReq := &services.BookQueryRequest{ Title: book.Title.String, } match, err := matchingService.QueryBooks(ctx, queryReq) if err != nil { continue } if len(match.Matches) > 0 && match.Matches[0].Confidence >= req.ConfidenceThreshold { bestMatch := match.Matches[0] _, err := h.db.CreateDeviceFileAlias(ctx, database.CreateDeviceFileAliasParams{ MediaItemID: pgtype.UUID{Bytes: bestMatch.BookhoardUUID, Valid: true}, DeviceID: book.DeviceID, FilePath: book.FilePath.String, FileSha256: pgtype.Text{String: "", Valid: false}, ConfidenceScore: toFloat8(bestMatch.Confidence), }) if err == nil { h.db.LinkUnlinkedBook(ctx, database.LinkUnlinkedBookParams{ ID: book.ID, MediaItemID: pgtype.UUID{Bytes: bestMatch.BookhoardUUID, Valid: true}, ConfidenceScore: toFloat8(bestMatch.Confidence), }) results = append(results, map[string]interface{}{ "unlinked_book_id": uuid.UUID(book.ID.Bytes).String(), "title": book.Title.String, "matched_media_item_id": bestMatch.BookhoardUUID.String(), "confidence": bestMatch.Confidence, "match_method": bestMatch.MatchMethod, }) } } } return c.JSON(http.StatusOK, map[string]interface{}{ "auto_linked": len(results), "results": results, }) } // GetUnlinkedBookSuggestions handles GET /api/sync/unlinked-books/:id/suggestions func (h *Handler) GetUnlinkedBookSuggestions(c *echo.Context) error { ctx := c.Request().Context() matchingService := h.getMatchingService() unlinkedBookID := c.Param("id") unlinkedUUID, err := uuid.Parse(unlinkedBookID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "Invalid unlinked book ID", }) } unlinked, err := h.db.GetUnlinkedBookByID(ctx, pgtype.UUID{Bytes: unlinkedUUID, Valid: true}) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{ "error": "Unlinked book not found", }) } matches, err := matchingService.QueryBooks(ctx, &services.BookQueryRequest{ Title: unlinked.Title.String, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ "error": "Failed to query matches", }) } return c.JSON(http.StatusOK, map[string]interface{}{ "unlinked_book_id": unlinkedBookID, "title_from_device": unlinked.Title.String, "sha256": "", "suggestions": matches.Matches, "total_suggestions": len(matches.Matches), "action": matches.Action, }) }