feat(sync): add bulk book linking and auto-linking features
- BulkLinkBooks: manually link multiple unlinked books to media items - AutoLinkBooks: automatically link books above confidence threshold - GetUnlinkedBookSuggestions: get match suggestions for specific unlinked book - Support batch operations with individual result tracking - Configurable confidence thresholds and limits
This commit is contained in:
@@ -17,6 +17,28 @@ func (h *Handler) getMatchingService() *services.BookMatchingService {
|
|||||||
return services.NewBookMatchingService(h.db)
|
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
|
// QueryBooks handles POST /api/sync/books/query
|
||||||
func (h *Handler) QueryBooks(c echo.Context) error {
|
func (h *Handler) QueryBooks(c echo.Context) error {
|
||||||
matchingService := h.getMatchingService()
|
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
|
// CreateDeviceFileAlias handles POST /api/devices/:id/file-aliases
|
||||||
func (h *Handler) CreateDeviceFileAlias(c echo.Context) error {
|
func (h *Handler) CreateDeviceFileAlias(c echo.Context) error {
|
||||||
deviceIDStr := c.Param("id")
|
deviceIDStr := c.Param("id")
|
||||||
|
|||||||
Reference in New Issue
Block a user