diff --git a/internal/handlers/book_matching.go b/internal/handlers/book_matching.go index 4970e3b..2575cbd 100644 --- a/internal/handlers/book_matching.go +++ b/internal/handlers/book_matching.go @@ -17,6 +17,28 @@ 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 +} + // QueryBooks handles POST /api/sync/books/query func (h *Handler) QueryBooks(c echo.Context) error { matchingService := h.getMatchingService() @@ -149,6 +171,200 @@ func (h *Handler) GetDeviceFileAliases(c echo.Context) error { }) } +// 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.BookmannUUID, 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.BookmannUUID, 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.BookmannUUID.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, + }) +} + // CreateDeviceFileAlias handles POST /api/devices/:id/file-aliases func (h *Handler) CreateDeviceFileAlias(c echo.Context) error { deviceIDStr := c.Param("id")