Files
bookhoard/internal/handlers/book_matching.go
T
john-okeefe b1a7d0a581 refactor: finalize media cleanup and prepare scan settings plan
- Complete media scanner cleanup (ebook → media terminology)
- Update remaining comments for consistency
- Add comprehensive scan settings migration plan
- Comment updates in book_matching.go and main.go
- Remove COMPLETE_MEDIA_CLEANUP_PLAN.md (completed)
- Add SCAN_SETTINGS_MIGRATION_PLAN.md for future implementation
2026-02-09 17:58:33 -05:00

551 lines
16 KiB
Go

package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
"fmt"
"net/http"
"strconv"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
)
// 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
}
// QueryBooks handles POST /api/sync/books/query
func (h *Handler) QueryBooks(c echo.Context) error {
matchingService := h.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/sync/link-book
func (h *Handler) LinkBook(c echo.Context) error {
matchingService := h.getMatchingService()
var req services.LinkBookRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid request body",
})
}
// Get device ID from context (set by auth middleware)
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/sync/unlinked-books
func (h *Handler) GetUnlinkedBooks(c echo.Context) error {
matchingService := h.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 (h *Handler) 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 := h.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",
})
}
// Convert to response format
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),
})
}
// 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,
})
}
// CreateDeviceFileAlias handles POST /api/devices/:id/file-aliases
func (h *Handler) 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 := h.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 (h *Handler) 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",
})
}
// Build update parameters
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 := h.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 (h *Handler) 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 = h.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 (h *Handler) GetBookMatches(c echo.Context) error {
matchingService := h.getMatchingService()
// Get query parameters
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)
}