diff --git a/internal/handlers/matching.go b/internal/handlers/matching.go new file mode 100644 index 0000000..962ab3c --- /dev/null +++ b/internal/handlers/matching.go @@ -0,0 +1,539 @@ +package handlers + +import ( + "bookhoard/internal/database" + "bookhoard/internal/services" + wsync "bookhoard/internal/sync" + "fmt" + "net/http" + "strconv" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" +) + +// MatchingHandler handles book matching, linking, and file alias operations +type MatchingHandler struct { + db *database.Queries + connManager *wsync.ConnectionManager +} + +// NewMatchingHandler creates a new matching handler +func NewMatchingHandler(db *database.Queries, connManager *wsync.ConnectionManager) *MatchingHandler { + return &MatchingHandler{ + db: db, + connManager: connManager, + } +} + +// getMatchingService creates a new book matching service +func (mh *MatchingHandler) getMatchingService() *services.BookMatchingService { + return services.NewBookMatchingService(mh.db) +} + +// QueryBooks handles POST /api/sync/books/query +func (mh *MatchingHandler) QueryBooks(c echo.Context) error { + matchingService := mh.getMatchingService() + + var req services.BookQueryRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid request body", + }) + } + + response, err := matchingService.QueryBooks(c.Request().Context(), &req) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to query books", + }) + } + + return c.JSON(http.StatusOK, response) +} + +// LinkBook handles POST /api/devices/:deviceId/sync/link-book +func (mh *MatchingHandler) LinkBook(c echo.Context) error { + matchingService := mh.getMatchingService() + + var req services.LinkBookRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid request body", + }) + } + + deviceIDStr := c.Param("deviceId") + deviceID, err := uuid.Parse(deviceIDStr) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid device ID", + }) + } + + alias, err := matchingService.LinkBook(c.Request().Context(), deviceID, &req) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": fmt.Sprintf("Failed to link book: %v", err), + }) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "status": "linked", + "device_file_alias": map[string]interface{}{ + "id": uuid.UUID(alias.ID.Bytes).String(), + "media_item_id": uuid.UUID(alias.MediaItemID.Bytes).String(), + "device_id": uuid.UUID(alias.DeviceID.Bytes).String(), + "file_path": alias.FilePath, + "file_sha256": alias.FileSha256.String, + "confidence_score": alias.ConfidenceScore.Float64, + }, + }) +} + +// GetUnlinkedBooks handles GET /api/devices/:deviceId/sync/unlinked-books +func (mh *MatchingHandler) GetUnlinkedBooks(c echo.Context) error { + matchingService := mh.getMatchingService() + + deviceIDStr := c.Param("deviceId") + deviceID, err := uuid.Parse(deviceIDStr) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid device ID", + }) + } + + unlinked, err := matchingService.GetUnlinkedBooks(c.Request().Context(), deviceID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to get unlinked books", + }) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "unlinked": unlinked, + "total": len(unlinked), + }) +} + +// GetDeviceFileAliases handles GET /api/devices/:id/file-aliases +func (mh *MatchingHandler) GetDeviceFileAliases(c echo.Context) error { + deviceIDStr := c.Param("id") + deviceID, err := uuid.Parse(deviceIDStr) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid device ID", + }) + } + + aliases, err := mh.db.GetDeviceFileAliasesByDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceID, Valid: true}) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to get file aliases", + }) + } + + type AliasResponse struct { + ID string `json:"id"` + MediaItemID string `json:"media_item_id"` + FilePath string `json:"file_path"` + FileSHA256 string `json:"file_sha256"` + ConfidenceScore float64 `json:"confidence_score"` + LastSeenAt string `json:"last_seen_at"` + } + + response := make([]AliasResponse, len(aliases)) + for i, alias := range aliases { + response[i] = AliasResponse{ + ID: uuid.UUID(alias.ID.Bytes).String(), + MediaItemID: uuid.UUID(alias.MediaItemID.Bytes).String(), + FilePath: alias.FilePath, + FileSHA256: alias.FileSha256.String, + ConfidenceScore: alias.ConfidenceScore.Float64, + LastSeenAt: alias.LastSeenAt.Time.String(), + } + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "device_id": deviceIDStr, + "aliases": response, + "total": len(response), + }) +} + +// CreateDeviceFileAlias handles POST /api/devices/:id/file-aliases +func (mh *MatchingHandler) CreateDeviceFileAlias(c echo.Context) error { + deviceIDStr := c.Param("id") + deviceID, err := uuid.Parse(deviceIDStr) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid device ID", + }) + } + + var req struct { + MediaItemID string `json:"media_item_id"` + FilePath string `json:"file_path"` + FileSHA256 string `json:"file_sha256"` + ConfidenceScore float64 `json:"confidence_score"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid request body", + }) + } + + mediaItemID, err := uuid.Parse(req.MediaItemID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid media item ID", + }) + } + + alias, err := mh.db.CreateDeviceFileAlias(c.Request().Context(), database.CreateDeviceFileAliasParams{ + MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, + DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true}, + FilePath: req.FilePath, + FileSha256: pgtype.Text{String: req.FileSHA256, Valid: req.FileSHA256 != ""}, + ConfidenceScore: pgtype.Float8{Float64: req.ConfidenceScore, Valid: true}, + }) + + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": fmt.Sprintf("Failed to create file alias: %v", err), + }) + } + + return c.JSON(http.StatusCreated, map[string]interface{}{ + "id": uuid.UUID(alias.ID.Bytes).String(), + "media_item_id": uuid.UUID(alias.MediaItemID.Bytes).String(), + "device_id": deviceIDStr, + "file_path": alias.FilePath, + "file_sha256": alias.FileSha256.String, + "confidence_score": alias.ConfidenceScore.Float64, + "last_seen_at": alias.LastSeenAt.Time.String(), + }) +} + +// UpdateDeviceFileAlias handles PUT /api/devices/:id/file-aliases/:aliasId +func (mh *MatchingHandler) UpdateDeviceFileAlias(c echo.Context) error { + aliasIDStr := c.Param("aliasId") + aliasID, err := uuid.Parse(aliasIDStr) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid alias ID", + }) + } + + var req struct { + MediaItemID *string `json:"media_item_id"` + FileSHA256 *string `json:"file_sha256"` + ConfidenceScore *float64 `json:"confidence_score"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid request body", + }) + } + + var mediaItemID pgtype.UUID + if req.MediaItemID != nil { + parsedID, err := uuid.Parse(*req.MediaItemID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid media item ID", + }) + } + mediaItemID = pgtype.UUID{Bytes: parsedID, Valid: true} + } + + var fileSHA256 pgtype.Text + if req.FileSHA256 != nil { + fileSHA256 = pgtype.Text{String: *req.FileSHA256, Valid: true} + } + + var confidenceScore pgtype.Float8 + if req.ConfidenceScore != nil { + confidenceScore = pgtype.Float8{Float64: *req.ConfidenceScore, Valid: true} + } + + alias, err := mh.db.UpdateDeviceFileAlias(c.Request().Context(), database.UpdateDeviceFileAliasParams{ + ID: pgtype.UUID{Bytes: aliasID, Valid: true}, + MediaItemID: mediaItemID, + FileSha256: fileSHA256, + ConfidenceScore: confidenceScore, + }) + + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": fmt.Sprintf("Failed to update file alias: %v", err), + }) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "id": uuid.UUID(alias.ID.Bytes).String(), + "media_item_id": uuid.UUID(alias.MediaItemID.Bytes).String(), + "file_path": alias.FilePath, + "file_sha256": alias.FileSha256.String, + "confidence_score": alias.ConfidenceScore.Float64, + "last_seen_at": alias.LastSeenAt.Time.String(), + }) +} + +// DeleteDeviceFileAlias handles DELETE /api/devices/:id/file-aliases/:aliasId +func (mh *MatchingHandler) DeleteDeviceFileAlias(c echo.Context) error { + aliasIDStr := c.Param("aliasId") + aliasID, err := uuid.Parse(aliasIDStr) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid alias ID", + }) + } + + err = mh.db.DeleteDeviceFileAlias(c.Request().Context(), pgtype.UUID{Bytes: aliasID, Valid: true}) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to delete file alias", + }) + } + + return c.JSON(http.StatusOK, map[string]string{ + "message": "File alias deleted successfully", + }) +} + +// GetBookMatches handles GET /api/books/match +func (mh *MatchingHandler) GetBookMatches(c echo.Context) error { + matchingService := mh.getMatchingService() + + identifiers := c.QueryParams()["identifier"] + sha256 := c.QueryParam("sha256") + title := c.QueryParam("title") + author := c.QueryParam("author") + fileSizeStr := c.QueryParam("file_size") + + var fileSize int64 + if fileSizeStr != "" { + size, err := strconv.ParseInt(fileSizeStr, 10, 64) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid file_size parameter", + }) + } + fileSize = size + } + + req := &services.BookQueryRequest{ + Identifiers: identifiers, + SHA256: sha256, + Title: title, + Author: author, + FileSize: fileSize, + } + + response, err := matchingService.QueryBooks(c.Request().Context(), req) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to query books", + }) + } + + return c.JSON(http.StatusOK, response) +} + +// BulkLinkBooks handles POST /api/sync/bulk-link-books +func (mh *MatchingHandler) 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 := mh.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 = mh.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}, + 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 = mh.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 (mh *MatchingHandler) AutoLinkBooks(c echo.Context) error { + ctx := c.Request().Context() + matchingService := mh.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 := mh.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 := mh.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 { + mh.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 (mh *MatchingHandler) GetUnlinkedBookSuggestions(c echo.Context) error { + ctx := c.Request().Context() + matchingService := mh.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 := mh.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, + }) +}