Files
bookhoard/internal/handlers/sync.go
T
john-okeefe 6495cc2c7c feat(handlers): Add OPDS, collections, book matching, and sync handlers
- Add OPDS handler for device catalog and book downloads
- Add collections handler for collection CRUD
- Add book matching service for cross-device book linking
- Add sidecar handler for Kobo metadata sync
- Add sync handler for device synchronization
2026-01-31 22:32:23 -05:00

155 lines
4.6 KiB
Go

package handlers
import (
"bookmann/internal/database"
"net/http"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
)
type SyncHandler struct {
db *database.Queries
}
func NewSyncHandler(db *database.Queries) *SyncHandler {
return &SyncHandler{db: db}
}
type UnlinkedBookResponse struct {
Unlinked []UnlinkedBookItem `json:"unlinked"`
Total int `json:"total"`
}
type UnlinkedBookItem struct {
UnlinkedBookID string `json:"unlinked_book_id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceType string `json:"device_type"`
ContentId string `json:"content_id"`
FilePath string `json:"file_path,omitempty"`
Title string `json:"title,omitempty"`
Author string `json:"author,omitempty"`
ConfidenceScore float64 `json:"confidence_score"`
LastSeenAt time.Time `json:"last_seen_at"`
CreatedAt time.Time `json:"created_at"`
}
type LinkBookRequest struct {
UnlinkedBookID string `json:"unlinked_book_id"`
MediaItemID string `json:"media_item_id"`
ConfidenceScore float64 `json:"confidence_score"`
}
type LinkBookResponse struct {
Status string `json:"status"`
UnlinkedBookID string `json:"unlinked_book_id"`
MediaItemID string `json:"media_item_id"`
Message string `json:"message"`
}
// GetUnlinkedBooks returns all unlinked books for a user's devices
// GET /api/sync/unlinked-books
func (h *SyncHandler) GetUnlinkedBooks(c echo.Context) error {
// Get user from context (assuming auth middleware sets this)
user := c.Get("user")
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "unauthorized",
})
}
userID := user.(database.Users).ID
// Get all devices for this user
devices, err := h.db.ListDevicesByUser(c.Request().Context(), pgtype.UUID{Bytes: userID.Bytes, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to fetch devices",
})
}
var allUnlinkedBooks []UnlinkedBookItem
// For each device, get unlinked books
for _, device := range devices {
unlinkedBooks, err := h.db.GetUnlinkedBooksByDevice(c.Request().Context(), pgtype.UUID{Bytes: device.ID.Bytes, Valid: true})
if err != nil {
continue
}
for _, ub := range unlinkedBooks {
allUnlinkedBooks = append(allUnlinkedBooks, UnlinkedBookItem{
UnlinkedBookID: uuid.UUID(ub.ID.Bytes).String(),
DeviceID: uuid.UUID(ub.DeviceID.Bytes).String(),
DeviceName: ub.DeviceName,
DeviceType: ub.DeviceType,
ContentId: ub.ContentID,
FilePath: ub.FilePath.String,
Title: ub.Title.String,
Author: ub.Author.String,
ConfidenceScore: ub.ConfidenceScore.Float64,
LastSeenAt: ub.LastSeenAt.Time,
CreatedAt: ub.CreatedAt.Time,
})
}
}
return c.JSON(http.StatusOK, UnlinkedBookResponse{
Unlinked: allUnlinkedBooks,
Total: len(allUnlinkedBooks),
})
}
// LinkBook manually links an unlinked book to a media item
// POST /api/sync/link-book
func (h *SyncHandler) LinkBook(c echo.Context) error {
var req LinkBookRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid request format",
})
}
// Parse UUIDs
unlinkedBookID, err := uuid.Parse(req.UnlinkedBookID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid unlinked_book_id",
})
}
mediaItemID, err := uuid.Parse(req.MediaItemID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid media_item_id",
})
}
// Get the unlinked books to find the one we're looking for
// We need to use GetAllUnlinkedBooks or create a new query
// For now, let's link directly using LinkUnlinkedBook
_, err = h.db.LinkUnlinkedBook(c.Request().Context(), database.LinkUnlinkedBookParams{
ID: pgtype.UUID{Bytes: unlinkedBookID, Valid: true},
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
ConfidenceScore: pgtype.Float8{Float64: req.ConfidenceScore, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to link book",
})
}
// Note: We can't create a device catalog entry without knowing the device_id and content_id
// In a real implementation, we'd first fetch the unlinked book, then create the catalog entry
return c.JSON(http.StatusOK, LinkBookResponse{
Status: "linked",
UnlinkedBookID: req.UnlinkedBookID,
MediaItemID: req.MediaItemID,
Message: "Book successfully linked",
})
}