Update all handler functions to use *echo.Context (pointer) instead of echo.Context (value) as required by Echo v5. Changes across all handler files: - analytics.go: Update handler signatures - auth.go: Update authentication handler signatures - book_matching.go: Update matching handler signatures - collections.go: Update collection handler signatures - collections_preview_test.go: Update test signatures - commonhandlers.go: Update common handler signatures - conflicts.go: Update conflict handler signatures - context.go: Update context handler signatures - dashboard.go: Update dashboard handler signatures - devices.go: Update device handler signatures - jobs.go: Update job handler signatures - kobo.go: Update Kobo handler signatures - koreader.go: Update Koreader handler signatures - library.go: Update library handler signatures - matching.go: Update matching handler signatures - media.go: Update media handler signatures - opds.go: Update OPDS handler signatures - progress.go: Update progress handler signatures - queue.go: Update queue handler signatures - refresh_token.go: Update token handler signatures - scanner.go: Update scanner handler signatures - sidecar.go: Update sidecar handler signatures - sync.go: Update sync handler signatures - system_settings.go: Update settings handler signatures - websocket.go: Update WebSocket handler signatures All handlers now properly implement Echo v5's pointer-based context pattern. This change is necessary for type safety and compatibility with Echo v5's improved context handling and WebSocket support.
551 lines
16 KiB
Go
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/v5"
|
|
)
|
|
|
|
// 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)
|
|
}
|