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/v5" ) // 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, }) }