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
This commit is contained in:
@@ -0,0 +1,334 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookmann/internal/database"
|
||||||
|
"bookmann/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 ebook.go initialization)
|
||||||
|
func (h *Handler) getMatchingService() *services.BookMatchingService {
|
||||||
|
return services.NewBookMatchingService(h.db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookmann/internal/database"
|
||||||
|
"bookmann/internal/services"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CollectionHandler struct {
|
||||||
|
db *database.Queries
|
||||||
|
collectionService *services.CollectionService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCollectionHandler(db *database.Queries) *CollectionHandler {
|
||||||
|
return &CollectionHandler{
|
||||||
|
db: db,
|
||||||
|
collectionService: services.NewCollectionService(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateCollectionRequest struct {
|
||||||
|
Name string `json:"name" validate:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
AutoAssignRules []services.Rule `json:"auto_assign_rules"`
|
||||||
|
ViewSettings map[string]interface{} `json:"view_settings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateCollectionRequest struct {
|
||||||
|
Name string `json:"name" validate:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
AutoAssignRules []services.Rule `json:"auto_assign_rules"`
|
||||||
|
ViewSettings map[string]interface{} `json:"view_settings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddBooksRequest struct {
|
||||||
|
BookIDs []string `json:"book_ids" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateDeviceMappingRequest struct {
|
||||||
|
CollectionID string `json:"collection_id" validate:"required"`
|
||||||
|
DeviceShelfName string `json:"device_shelf_name" validate:"required"`
|
||||||
|
SyncDirection string `json:"sync_direction" validate:"required,oneof=bidirectional book_to_device device_to_book none"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateDeviceMappingRequest struct {
|
||||||
|
DeviceShelfName string `json:"device_shelf_name" validate:"required"`
|
||||||
|
SyncDirection string `json:"sync_direction" validate:"required,oneof=bidirectional book_to_device device_to_book none"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) CreateCollection(c echo.Context) error {
|
||||||
|
user := c.Get("user").(database.Users)
|
||||||
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
|
|
||||||
|
var req CreateCollectionRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
if err := c.Validate(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
collection, err := h.collectionService.CreateCollection(
|
||||||
|
c.Request().Context(),
|
||||||
|
userUUID,
|
||||||
|
req.Name,
|
||||||
|
req.Description,
|
||||||
|
req.Color,
|
||||||
|
req.Icon,
|
||||||
|
req.AutoAssignRules,
|
||||||
|
req.ViewSettings,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
bookCount := int32(0)
|
||||||
|
return c.JSON(http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||||
|
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
||||||
|
"name": collection.Name,
|
||||||
|
"description": textToString(collection.Description),
|
||||||
|
"color": textToString(collection.Color),
|
||||||
|
"icon": textToString(collection.Icon),
|
||||||
|
"auto_assign_rules": collection.AutoAssignRules,
|
||||||
|
"view_settings": collection.ViewSettings,
|
||||||
|
"book_count": bookCount,
|
||||||
|
"created_at": collection.CreatedAt.Time.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) GetCollections(c echo.Context) error {
|
||||||
|
includeAuto := c.QueryParam("include_auto") == "true"
|
||||||
|
sortBy := c.QueryParam("sort_by")
|
||||||
|
|
||||||
|
collections, err := h.GetCollectionsData(c)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollectionResponse struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]CollectionResponse, 0, len(collections))
|
||||||
|
for _, col := range collections {
|
||||||
|
if !includeAuto && len(col.AutoAssignRules) > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
response = append(response, CollectionResponse{
|
||||||
|
ID: uuid.UUID(col.ID.Bytes),
|
||||||
|
Name: col.Name,
|
||||||
|
Description: textToString(col.Description),
|
||||||
|
Color: textToString(col.Color),
|
||||||
|
Icon: textToString(col.Icon),
|
||||||
|
AutoAssignRules: json.RawMessage(col.AutoAssignRules),
|
||||||
|
CreatedAt: col.CreatedAt.Time.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if sortBy == "name" {
|
||||||
|
for i := 0; i < len(response); i++ {
|
||||||
|
for j := i + 1; j < len(response); j++ {
|
||||||
|
if response[i].Name > response[j].Name {
|
||||||
|
response[i], response[j] = response[j], response[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"collections": response,
|
||||||
|
"total": len(response),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) GetCollection(c echo.Context) error {
|
||||||
|
collectionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
collection, err := h.GetCollectionData(c, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
books, err := h.GetCollectionBooksData(c, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
type BookInfo struct {
|
||||||
|
MediaItemID string `json:"media_item_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
CoverImagePath string `json:"cover_image_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
bookList := make([]BookInfo, 0, len(books))
|
||||||
|
for _, book := range books {
|
||||||
|
bookList = append(bookList, BookInfo{
|
||||||
|
MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(),
|
||||||
|
Title: book.Title,
|
||||||
|
Author: textToString(book.Author),
|
||||||
|
CoverImagePath: textToString(book.CoverImagePath),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var viewSettings map[string]interface{}
|
||||||
|
if len(collection.ViewSettings) > 0 {
|
||||||
|
json.Unmarshal(collection.ViewSettings, &viewSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||||
|
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
||||||
|
"name": collection.Name,
|
||||||
|
"description": textToString(collection.Description),
|
||||||
|
"color": textToString(collection.Color),
|
||||||
|
"icon": textToString(collection.Icon),
|
||||||
|
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
|
||||||
|
"view_settings": viewSettings,
|
||||||
|
"books": bookList,
|
||||||
|
"book_count": len(bookList),
|
||||||
|
"created_at": collection.CreatedAt.Time.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) UpdateCollection(c echo.Context) error {
|
||||||
|
collectionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req UpdateCollectionRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
if err := c.Validate(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
collection, err := h.collectionService.UpdateCollection(
|
||||||
|
c.Request().Context(),
|
||||||
|
collectionID,
|
||||||
|
req.Name,
|
||||||
|
req.Description,
|
||||||
|
req.Color,
|
||||||
|
req.Icon,
|
||||||
|
req.AutoAssignRules,
|
||||||
|
req.ViewSettings,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||||
|
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
||||||
|
"name": collection.Name,
|
||||||
|
"description": textToString(collection.Description),
|
||||||
|
"color": textToString(collection.Color),
|
||||||
|
"icon": textToString(collection.Icon),
|
||||||
|
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
|
||||||
|
"view_settings": json.RawMessage(collection.ViewSettings),
|
||||||
|
"created_at": collection.CreatedAt.Time.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) DeleteCollection(c echo.Context) error {
|
||||||
|
collectionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.collectionService.DeleteCollection(c.Request().Context(), collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) AddBooks(c echo.Context) error {
|
||||||
|
collectionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
user := c.Get("user").(database.Users)
|
||||||
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
|
|
||||||
|
var req AddBooksRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
if err := c.Validate(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bookIDStr := range req.BookIDs {
|
||||||
|
bookID, err := uuid.Parse(bookIDStr)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.collectionService.AddBookToCollection(c.Request().Context(), collectionID, bookID, userUUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) RemoveBook(c echo.Context) error {
|
||||||
|
collectionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
bookID, err := uuid.Parse(c.Param("bookId"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.collectionService.RemoveBookFromCollection(c.Request().Context(), collectionID, bookID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) GetDeviceMappings(c echo.Context) error {
|
||||||
|
deviceID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
mappings, err := h.collectionService.GetDeviceShelfMappings(c.Request().Context(), deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
type MappingResponse struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
CollectionID uuid.UUID `json:"collection_id"`
|
||||||
|
CollectionName string `json:"collection_name"`
|
||||||
|
DeviceShelfName string `json:"device_shelf_name"`
|
||||||
|
SyncDirection string `json:"sync_direction"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]MappingResponse, 0, len(mappings))
|
||||||
|
for _, m := range mappings {
|
||||||
|
response = append(response, MappingResponse{
|
||||||
|
ID: uuid.UUID(m.ID.Bytes),
|
||||||
|
CollectionID: uuid.UUID(m.CollectionID.Bytes),
|
||||||
|
CollectionName: m.CollectionName,
|
||||||
|
DeviceShelfName: textToString(m.DeviceShelfName),
|
||||||
|
SyncDirection: textToString(m.SyncDirection),
|
||||||
|
CreatedAt: m.CreatedAt.Time.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"device_id": deviceID.String(),
|
||||||
|
"mappings": response,
|
||||||
|
"total": len(response),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) CreateDeviceMapping(c echo.Context) error {
|
||||||
|
deviceID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CreateDeviceMappingRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
if err := c.Validate(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionID, err := uuid.Parse(req.CollectionID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
mapping, err := h.collectionService.CreateDeviceShelfMapping(
|
||||||
|
c.Request().Context(),
|
||||||
|
collectionID,
|
||||||
|
deviceID,
|
||||||
|
req.DeviceShelfName,
|
||||||
|
req.SyncDirection,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": uuid.UUID(mapping.ID.Bytes).String(),
|
||||||
|
"collection_id": uuid.UUID(mapping.CollectionID.Bytes).String(),
|
||||||
|
"device_id": uuid.UUID(mapping.DeviceID.Bytes).String(),
|
||||||
|
"device_shelf_name": textToString(mapping.DeviceShelfName),
|
||||||
|
"sync_direction": textToString(mapping.SyncDirection),
|
||||||
|
"created_at": mapping.CreatedAt.Time.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) UpdateDeviceMapping(c echo.Context) error {
|
||||||
|
mappingID, err := uuid.Parse(c.Param("mappingId"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid mapping id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req UpdateDeviceMappingRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
if err := c.Validate(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
mapping, err := h.collectionService.UpdateDeviceShelfMapping(
|
||||||
|
c.Request().Context(),
|
||||||
|
mappingID,
|
||||||
|
req.DeviceShelfName,
|
||||||
|
req.SyncDirection,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"id": uuid.UUID(mapping.ID.Bytes).String(),
|
||||||
|
"collection_id": uuid.UUID(mapping.CollectionID.Bytes).String(),
|
||||||
|
"device_id": uuid.UUID(mapping.DeviceID.Bytes).String(),
|
||||||
|
"device_shelf_name": textToString(mapping.DeviceShelfName),
|
||||||
|
"sync_direction": textToString(mapping.SyncDirection),
|
||||||
|
"created_at": mapping.CreatedAt.Time.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) DeleteDeviceMapping(c echo.Context) error {
|
||||||
|
mappingID, err := uuid.Parse(c.Param("mappingId"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid mapping id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.collectionService.DeleteDeviceShelfMapping(c.Request().Context(), mappingID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) GetBookCollections(c echo.Context) error {
|
||||||
|
bookID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
collections, err := h.collectionService.GetBookCollections(c.Request().Context(), bookID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollectionResponse struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]CollectionResponse, 0, len(collections))
|
||||||
|
for _, col := range collections {
|
||||||
|
response = append(response, CollectionResponse{
|
||||||
|
ID: uuid.UUID(col.ID.Bytes),
|
||||||
|
Name: col.Name,
|
||||||
|
Description: textToString(col.Description),
|
||||||
|
Color: textToString(col.Color),
|
||||||
|
Icon: textToString(col.Icon),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"book_id": bookID.String(),
|
||||||
|
"collections": response,
|
||||||
|
"total": len(response),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func textToString(t pgtype.Text) string {
|
||||||
|
if t.Valid {
|
||||||
|
return t.String
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) GetCollectionsData(c echo.Context) ([]database.Collections, error) {
|
||||||
|
user := c.Get("user").(database.Users)
|
||||||
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
|
return h.collectionService.GetUserCollections(c.Request().Context(), userUUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) GetCollectionData(c echo.Context, collectionID uuid.UUID) (database.Collections, error) {
|
||||||
|
return h.collectionService.GetCollection(c.Request().Context(), collectionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) GetCollectionBooksData(c echo.Context, collectionID uuid.UUID) ([]database.GetCollectionItemsRow, error) {
|
||||||
|
return h.collectionService.GetCollectionBooks(c.Request().Context(), collectionID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,796 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookmann/internal/database"
|
||||||
|
"bookmann/internal/opds"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OPDSHandler struct {
|
||||||
|
db *database.Queries
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOPDSHandler(db *database.Queries) *OPDSHandler {
|
||||||
|
return &OPDSHandler{
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get base URLs from system config
|
||||||
|
func (h *OPDSHandler) getBaseURLs(c echo.Context) (string, string, error) {
|
||||||
|
baseURL, err := h.db.GetSystemConfig(c.Request().Context(), "base_url")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to get base_url from config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opdsBaseURL, err := h.db.GetSystemConfig(c.Request().Context(), "opds_base_url")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to get opds_base_url from config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseURL.Value, opdsBaseURL.Value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeviceCatalog returns the OPDS catalog feed for a device
|
||||||
|
func (h *OPDSHandler) GetDeviceCatalog(c echo.Context) error {
|
||||||
|
deviceID := c.Param("deviceId")
|
||||||
|
page := c.QueryParam("page")
|
||||||
|
perPage := c.QueryParam("per_page")
|
||||||
|
includeFormat := c.QueryParam("include_format")
|
||||||
|
|
||||||
|
// Parse pagination parameters
|
||||||
|
pageNum := 1
|
||||||
|
if page != "" {
|
||||||
|
if num, err := strconv.Atoi(page); err == nil && num > 0 {
|
||||||
|
pageNum = num
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
perPageNum := 50
|
||||||
|
if perPage != "" {
|
||||||
|
if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= 200 {
|
||||||
|
perPageNum = num
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get base URLs
|
||||||
|
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
|
||||||
|
if err != nil {
|
||||||
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get config"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse device ID
|
||||||
|
deviceUUID, err := uuid.Parse(deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid device ID"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify device exists
|
||||||
|
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.XML(http.StatusNotFound, opds.NewErrorFeed("Device not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's visible libraries
|
||||||
|
userID := device.UserID.Bytes
|
||||||
|
userUUID := uuid.UUID(userID)
|
||||||
|
|
||||||
|
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get media items
|
||||||
|
var allItems []database.ListMediaItemsByLibraryRow
|
||||||
|
for _, lib := range libraries {
|
||||||
|
items, err := h.db.ListMediaItemsByLibrary(c.Request().Context(), pgtype.UUID{Bytes: lib.ID.Bytes, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allItems = append(allItems, items...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by format if specified
|
||||||
|
if includeFormat != "" {
|
||||||
|
filteredItems := []database.ListMediaItemsByLibraryRow{}
|
||||||
|
for _, item := range allItems {
|
||||||
|
if strings.ToLower(item.FormatGroup) == strings.ToLower(includeFormat) {
|
||||||
|
filteredItems = append(filteredItems, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allItems = filteredItems
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
totalItems := len(allItems)
|
||||||
|
startIdx := (pageNum - 1) * perPageNum
|
||||||
|
endIdx := startIdx + perPageNum
|
||||||
|
|
||||||
|
if startIdx >= totalItems {
|
||||||
|
allItems = []database.ListMediaItemsByLibraryRow{}
|
||||||
|
} else if endIdx > totalItems {
|
||||||
|
allItems = allItems[startIdx:]
|
||||||
|
} else {
|
||||||
|
allItems = allItems[startIdx:endIdx]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create OPDS feed
|
||||||
|
feed := opds.NewFeed(
|
||||||
|
fmt.Sprintf("urn:uuid:%s", deviceID),
|
||||||
|
"Bookmann Library",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add feed links
|
||||||
|
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||||
|
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
||||||
|
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
||||||
|
|
||||||
|
searchURL := fmt.Sprintf("%s/opds/devices/%s/search", opdsBaseURL, deviceID)
|
||||||
|
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "search")
|
||||||
|
|
||||||
|
// Add entries
|
||||||
|
for _, item := range allItems {
|
||||||
|
bookUUID := uuid.UUID(item.ID.Bytes).String()
|
||||||
|
title := item.Title
|
||||||
|
author := ""
|
||||||
|
if item.Author.Valid {
|
||||||
|
author = item.Author.String
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z")
|
||||||
|
|
||||||
|
entry := opds.NewEntry(
|
||||||
|
fmt.Sprintf("urn:uuid:%s", bookUUID),
|
||||||
|
title,
|
||||||
|
author,
|
||||||
|
updated,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add description
|
||||||
|
if item.Description.Valid {
|
||||||
|
entry.SetSummary(item.Description.String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add acquisition links
|
||||||
|
downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID)
|
||||||
|
entry.AddAcquisitionLink(downloadURL, "application/epub+zip")
|
||||||
|
|
||||||
|
// Add format variants
|
||||||
|
kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL)
|
||||||
|
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
||||||
|
|
||||||
|
pdfURL := fmt.Sprintf("%s?format=pdf", downloadURL)
|
||||||
|
entry.AddAlternateLink(pdfURL, "application/pdf")
|
||||||
|
|
||||||
|
// Add canonical identifier
|
||||||
|
entry.SetIdentifier(bookUUID)
|
||||||
|
|
||||||
|
// Add SHA-256 metadata
|
||||||
|
if item.FileSha256.Valid {
|
||||||
|
entry.AddMetadata("bookmann:sha256", item.FileSha256.String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add collections as categories
|
||||||
|
collections, err := h.db.GetCollectionsForBook(c.Request().Context(), pgtype.UUID{Bytes: item.ID.Bytes, Valid: true})
|
||||||
|
if err == nil {
|
||||||
|
collectionScheme := fmt.Sprintf("%s/collections", baseURL)
|
||||||
|
for _, col := range collections {
|
||||||
|
if col.UserID.Valid && uuid.UUID(col.UserID.Bytes) == userUUID {
|
||||||
|
entry.AddCategory(collectionScheme, col.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
feed.AddEntry(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate XML
|
||||||
|
xmlString, err := feed.GenerateXMLString()
|
||||||
|
if err != nil {
|
||||||
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Response().Header().Set("Content-Type", "application/atom+xml;profile=opds-catalog;kind=acquisition")
|
||||||
|
return c.String(http.StatusOK, xmlString)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchDeviceCatalog searches the OPDS catalog for a device
|
||||||
|
func (h *OPDSHandler) SearchDeviceCatalog(c echo.Context) error {
|
||||||
|
deviceID := c.Param("deviceId")
|
||||||
|
query := c.QueryParam("q")
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Missing search query"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get base URLs
|
||||||
|
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
|
||||||
|
if err != nil {
|
||||||
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get config"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse device ID
|
||||||
|
deviceUUID, err := uuid.Parse(deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid device ID"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify device exists
|
||||||
|
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.XML(http.StatusNotFound, opds.NewErrorFeed("Device not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's visible libraries
|
||||||
|
userID := device.UserID.Bytes
|
||||||
|
|
||||||
|
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search media items
|
||||||
|
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
|
||||||
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
SearchPattern: pgtype.Text{String: "%" + query + "%", Valid: true},
|
||||||
|
Offset: pgtype.Int4{Int32: 0, Valid: true},
|
||||||
|
Limit: pgtype.Int4{Int32: 1000, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
allItems = []database.SearchMediaItemsRow{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create OPDS feed
|
||||||
|
feed := opds.NewFeed(
|
||||||
|
fmt.Sprintf("urn:uuid:%s", deviceID),
|
||||||
|
fmt.Sprintf("Search: %s", query),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add feed links
|
||||||
|
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||||
|
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
||||||
|
|
||||||
|
searchURL := fmt.Sprintf("%s/opds/devices/%s/search?q=%s", opdsBaseURL, deviceID, query)
|
||||||
|
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
||||||
|
|
||||||
|
// Add entries (same as catalog)
|
||||||
|
userUUID := uuid.UUID(userID)
|
||||||
|
for _, item := range allItems {
|
||||||
|
bookUUID := uuid.UUID(item.ID.Bytes).String()
|
||||||
|
title := item.Title
|
||||||
|
author := ""
|
||||||
|
if item.Author.Valid {
|
||||||
|
author = item.Author.String
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z")
|
||||||
|
|
||||||
|
entry := opds.NewEntry(
|
||||||
|
fmt.Sprintf("urn:uuid:%s", bookUUID),
|
||||||
|
title,
|
||||||
|
author,
|
||||||
|
updated,
|
||||||
|
)
|
||||||
|
|
||||||
|
if item.Description.Valid {
|
||||||
|
entry.SetSummary(item.Description.String)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID)
|
||||||
|
entry.AddAcquisitionLink(downloadURL, "application/epub+zip")
|
||||||
|
|
||||||
|
kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL)
|
||||||
|
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
||||||
|
|
||||||
|
entry.SetIdentifier(bookUUID)
|
||||||
|
|
||||||
|
if item.FileSha256.Valid {
|
||||||
|
entry.AddMetadata("bookmann:sha256", item.FileSha256.String)
|
||||||
|
}
|
||||||
|
|
||||||
|
collections, err := h.db.GetCollectionsForBook(c.Request().Context(), pgtype.UUID{Bytes: item.ID.Bytes, Valid: true})
|
||||||
|
if err == nil {
|
||||||
|
collectionScheme := fmt.Sprintf("%s/collections", baseURL)
|
||||||
|
for _, col := range collections {
|
||||||
|
if col.UserID.Valid && uuid.UUID(col.UserID.Bytes) == userUUID {
|
||||||
|
entry.AddCategory(collectionScheme, col.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
feed.AddEntry(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
xmlString, err := feed.GenerateXMLString()
|
||||||
|
if err != nil {
|
||||||
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Response().Header().Set("Content-Type", "application/atom+xml;profile=opds-catalog;kind=acquisition")
|
||||||
|
return c.String(http.StatusOK, xmlString)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadBook downloads a book with optional format conversion
|
||||||
|
func (h *OPDSHandler) DownloadBook(c echo.Context) error {
|
||||||
|
deviceID := c.Param("deviceId")
|
||||||
|
bookID := c.Param("bookId")
|
||||||
|
format := c.QueryParam("format") // epub, kepub, pdf, cbz
|
||||||
|
|
||||||
|
// Default to epub
|
||||||
|
if format == "" {
|
||||||
|
format = "epub"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse IDs
|
||||||
|
deviceUUID, err := uuid.Parse(deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
bookUUID, err := uuid.Parse(bookID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify device exists
|
||||||
|
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get media item
|
||||||
|
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: bookUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's visible libraries
|
||||||
|
userID := device.UserID.Bytes
|
||||||
|
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if book is in visible library
|
||||||
|
visible := false
|
||||||
|
for _, lib := range libraries {
|
||||||
|
if uuid.UUID(lib.ID.Bytes) == uuid.UUID(mediaItem.LibraryID.Bytes) {
|
||||||
|
visible = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !visible {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "book not accessible"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get format information
|
||||||
|
filePath := ""
|
||||||
|
var fileSha256 string
|
||||||
|
mimeType := ""
|
||||||
|
|
||||||
|
if format == "kepub" {
|
||||||
|
// Check for pre-converted KEPUB
|
||||||
|
kepubFormat, err := h.db.GetMediaItemFormatByType(c.Request().Context(), database.GetMediaItemFormatByTypeParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: bookUUID, Valid: true},
|
||||||
|
FormatType: "kepub",
|
||||||
|
})
|
||||||
|
if err == nil && kepubFormat.FilePath.Valid {
|
||||||
|
filePath = kepubFormat.FilePath.String
|
||||||
|
if kepubFormat.FileSha256.Valid {
|
||||||
|
fileSha256 = kepubFormat.FileSha256.String
|
||||||
|
}
|
||||||
|
if kepubFormat.MimeType.Valid {
|
||||||
|
mimeType = kepubFormat.MimeType.String
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fall back to EPUB (no on-the-fly conversion for now)
|
||||||
|
filePath = mediaItem.FilePath
|
||||||
|
if mediaItem.MimeType.Valid {
|
||||||
|
mimeType = mediaItem.MimeType.String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if format == "pdf" {
|
||||||
|
// Check for PDF format
|
||||||
|
pdfFormat, err := h.db.GetMediaItemFormatByType(c.Request().Context(), database.GetMediaItemFormatByTypeParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: bookUUID, Valid: true},
|
||||||
|
FormatType: "pdf",
|
||||||
|
})
|
||||||
|
if err == nil && pdfFormat.FilePath.Valid {
|
||||||
|
filePath = pdfFormat.FilePath.String
|
||||||
|
if pdfFormat.FileSha256.Valid {
|
||||||
|
fileSha256 = pdfFormat.FileSha256.String
|
||||||
|
}
|
||||||
|
if pdfFormat.MimeType.Valid {
|
||||||
|
mimeType = pdfFormat.MimeType.String
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "pdf format not available"})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default: EPUB
|
||||||
|
filePath = mediaItem.FilePath
|
||||||
|
if mediaItem.MimeType.Valid {
|
||||||
|
mimeType = mediaItem.MimeType.String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open file
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to open file"})
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Get file info
|
||||||
|
fileInfo, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get file info"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
filename := filepath.Base(filePath)
|
||||||
|
c.Response().Header().Set("Content-Type", mimeType)
|
||||||
|
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
|
c.Response().Header().Set("X-Bookmann-UUID", bookUUID.String())
|
||||||
|
|
||||||
|
if fileSha256 != "" {
|
||||||
|
c.Response().Header().Set("X-Bookmann-SHA256", fileSha256)
|
||||||
|
}
|
||||||
|
if format == "kepub" && fileSha256 != "" {
|
||||||
|
c.Response().Header().Set("X-Bookmann-KEPUB-SHA256", fileSha256)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream file
|
||||||
|
http.ServeContent(c.Response(), c.Request(), filename, fileInfo.ModTime(), file)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCoverImage serves a book's cover image
|
||||||
|
func (h *OPDSHandler) GetCoverImage(c echo.Context) error {
|
||||||
|
deviceID := c.Param("deviceId")
|
||||||
|
bookID := c.Param("bookId")
|
||||||
|
|
||||||
|
// Parse IDs
|
||||||
|
deviceUUID, err := uuid.Parse(deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
bookUUID, err := uuid.Parse(bookID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify device exists
|
||||||
|
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get media item
|
||||||
|
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: bookUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's visible libraries
|
||||||
|
userID := device.UserID.Bytes
|
||||||
|
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if book is in visible library
|
||||||
|
visible := false
|
||||||
|
for _, lib := range libraries {
|
||||||
|
if uuid.UUID(lib.ID.Bytes) == uuid.UUID(mediaItem.LibraryID.Bytes) {
|
||||||
|
visible = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !visible {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "book not accessible"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cover exists
|
||||||
|
if !mediaItem.CoverImagePath.Valid || mediaItem.CoverImagePath.String == "" {
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
coverPath := mediaItem.CoverImagePath.String
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if _, err := os.Stat(coverPath); os.IsNotExist(err) {
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open file
|
||||||
|
file, err := os.Open(coverPath)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to open cover"})
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Get file info
|
||||||
|
fileInfo, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get file info"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine content type
|
||||||
|
ext := strings.ToLower(filepath.Ext(coverPath))
|
||||||
|
contentType := "image/jpeg"
|
||||||
|
if ext == ".png" {
|
||||||
|
contentType = "image/png"
|
||||||
|
} else if ext == ".webp" {
|
||||||
|
contentType = "image/webp"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set cache headers
|
||||||
|
c.Response().Header().Set("Content-Type", contentType)
|
||||||
|
c.Response().Header().Set("Cache-Control", "public, max-age=31536000")
|
||||||
|
|
||||||
|
// Stream image
|
||||||
|
http.ServeContent(c.Response(), c.Request(), "", fileInfo.ModTime(), file)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeviceNavigation returns the OPDS navigation feed for a device
|
||||||
|
func (h *OPDSHandler) GetDeviceNavigation(c echo.Context) error {
|
||||||
|
deviceID := c.Param("deviceId")
|
||||||
|
|
||||||
|
// Get base URLs
|
||||||
|
_, opdsBaseURL, err := h.getBaseURLs(c)
|
||||||
|
if err != nil {
|
||||||
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get config"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse device ID
|
||||||
|
deviceUUID, err := uuid.Parse(deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid device ID"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify device exists
|
||||||
|
_, err = h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.XML(http.StatusNotFound, opds.NewErrorFeed("Device not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create OPDS navigation feed
|
||||||
|
feed := opds.NewFeed(
|
||||||
|
fmt.Sprintf("urn:uuid:%s", deviceID),
|
||||||
|
"Bookmann Library Navigation",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add feed links
|
||||||
|
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||||
|
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
||||||
|
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
||||||
|
|
||||||
|
// Add navigation entry for catalog
|
||||||
|
catalogEntry := opds.NewEntry(
|
||||||
|
fmt.Sprintf("urn:uuid:%s-catalog", deviceID),
|
||||||
|
"All Books",
|
||||||
|
"",
|
||||||
|
feed.Updated,
|
||||||
|
)
|
||||||
|
catalogEntry.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "subsection")
|
||||||
|
catalogEntry.SetSummary("Browse all books in the library")
|
||||||
|
feed.AddEntry(catalogEntry)
|
||||||
|
|
||||||
|
// Generate XML
|
||||||
|
xmlString, err := feed.GenerateXMLString()
|
||||||
|
if err != nil {
|
||||||
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Response().Header().Set("Content-Type", "application/atom+xml;profile=opds-catalog;kind=navigation")
|
||||||
|
return c.String(http.StatusOK, xmlString)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFormats lists available formats for a book
|
||||||
|
func (h *OPDSHandler) ListFormats(c echo.Context) error {
|
||||||
|
deviceID := c.Param("deviceId")
|
||||||
|
bookID := c.Param("bookId")
|
||||||
|
|
||||||
|
// Parse IDs
|
||||||
|
deviceUUID, err := uuid.Parse(deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
bookUUID, err := uuid.Parse(bookID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify device exists
|
||||||
|
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get media item
|
||||||
|
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: bookUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's visible libraries
|
||||||
|
userID := device.UserID.Bytes
|
||||||
|
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get libraries"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if book is in visible library
|
||||||
|
visible := false
|
||||||
|
for _, lib := range libraries {
|
||||||
|
if uuid.UUID(lib.ID.Bytes) == uuid.UUID(mediaItem.LibraryID.Bytes) {
|
||||||
|
visible = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !visible {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "book not accessible"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all formats
|
||||||
|
formats, err := h.db.GetMediaItemFormats(c.Request().Context(), pgtype.UUID{Bytes: bookUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get formats"})
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormatInfo struct {
|
||||||
|
FormatType string `json:"format_type"`
|
||||||
|
FilePath string `json:"file_path,omitempty"`
|
||||||
|
FileSha256 string `json:"file_sha256,omitempty"`
|
||||||
|
FileSize int64 `json:"file_size_bytes,omitempty"`
|
||||||
|
MimeType string `json:"mime_type,omitempty"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
}
|
||||||
|
|
||||||
|
formatList := []FormatInfo{}
|
||||||
|
|
||||||
|
// Add EPUB format (always available if media item exists)
|
||||||
|
fileSize := int64(0)
|
||||||
|
if mediaItem.FileSize.Valid {
|
||||||
|
fileSize = mediaItem.FileSize.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
formatList = append(formatList, FormatInfo{
|
||||||
|
FormatType: "epub",
|
||||||
|
FilePath: mediaItem.FilePath,
|
||||||
|
FileSha256: func() string {
|
||||||
|
if mediaItem.FileSha256.Valid {
|
||||||
|
return mediaItem.FileSha256.String
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}(),
|
||||||
|
FileSize: fileSize,
|
||||||
|
MimeType: func() string {
|
||||||
|
if mediaItem.MimeType.Valid {
|
||||||
|
return mediaItem.MimeType.String
|
||||||
|
} else {
|
||||||
|
return "application/epub+zip"
|
||||||
|
}
|
||||||
|
}(),
|
||||||
|
Available: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add other formats
|
||||||
|
for _, format := range formats {
|
||||||
|
fileSize := int64(0)
|
||||||
|
if format.FileSizeBytes.Valid {
|
||||||
|
fileSize = format.FileSizeBytes.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
formatList = append(formatList, FormatInfo{
|
||||||
|
FormatType: format.FormatType,
|
||||||
|
FilePath: func() string {
|
||||||
|
if format.FilePath.Valid {
|
||||||
|
return format.FilePath.String
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}(),
|
||||||
|
FileSha256: func() string {
|
||||||
|
if format.FileSha256.Valid {
|
||||||
|
return format.FileSha256.String
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}(),
|
||||||
|
FileSize: fileSize,
|
||||||
|
MimeType: func() string {
|
||||||
|
if format.MimeType.Valid {
|
||||||
|
return format.MimeType.String
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}(),
|
||||||
|
Available: format.FilePath.Valid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"media_item_id": bookUUID.String(),
|
||||||
|
"formats": formatList,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterOPDS registers a device for OPDS access
|
||||||
|
func (h *OPDSHandler) RegisterOPDS(c echo.Context) error {
|
||||||
|
deviceID := c.Param("deviceId")
|
||||||
|
|
||||||
|
// Parse device ID
|
||||||
|
deviceUUID, err := uuid.Parse(deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify device exists
|
||||||
|
_, err = h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get base URLs
|
||||||
|
_, opdsBaseURL, err := h.getBaseURLs(c)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get config"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate OPDS token
|
||||||
|
tokenBytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(tokenBytes); err != nil {
|
||||||
|
tokenBytes = []byte(deviceID + "-" + uuid.New().String())
|
||||||
|
}
|
||||||
|
token := hex.EncodeToString(tokenBytes)[:64]
|
||||||
|
|
||||||
|
// Create OPDS token record (valid for 1 year)
|
||||||
|
expiresAt := time.Now().AddDate(1, 0, 0)
|
||||||
|
_, err = h.db.CreateOpdsToken(c.Request().Context(), database.CreateOpdsTokenParams{
|
||||||
|
DeviceID: pgtype.UUID{Bytes: deviceUUID, Valid: true},
|
||||||
|
Token: token,
|
||||||
|
TokenType: pgtype.Text{String: "device", Valid: true},
|
||||||
|
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create OPDS token"})
|
||||||
|
}
|
||||||
|
|
||||||
|
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"opds_token": map[string]interface{}{
|
||||||
|
"token": token,
|
||||||
|
"token_type": "device",
|
||||||
|
"expires_at": expiresAt.Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
"opds_catalog_url": catalogURL,
|
||||||
|
"refresh_interval": 3600,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookmann/internal/database"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SidecarHandler struct {
|
||||||
|
db *database.Queries
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSidecarHandler(db *database.Queries) *SidecarHandler {
|
||||||
|
return &SidecarHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SidecarConfig struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Bookmann SidecarBookmannConfig `json:"bookmann"`
|
||||||
|
Books map[string]SidecarBook `json:"books"`
|
||||||
|
Collections []SidecarCollection `json:"collections"`
|
||||||
|
OPDSEnabled bool `json:"opds_enabled"`
|
||||||
|
SidecarEnabled bool `json:"sidecar_enabled"`
|
||||||
|
LastUpdated string `json:"last_updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SidecarBookmannConfig struct {
|
||||||
|
OPDSCatalog string `json:"opds_catalog"`
|
||||||
|
SyncAPI string `json:"sync_api"`
|
||||||
|
OPDSBaseURL string `json:"opds_base_url"`
|
||||||
|
APIBaseURL string `json:"api_base_url"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
DeviceToken string `json:"device_token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SidecarBook struct {
|
||||||
|
BookmannUUID string `json:"bookmann_uuid"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
AvailableFormats []string `json:"available_formats"`
|
||||||
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
|
FilePath string `json:"file_path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SidecarCollection struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ShelfMapping string `json:"shelf_mapping,omitempty"`
|
||||||
|
BookIDs []string `json:"book_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSidecarConfig generates and returns sidecar configuration for a device
|
||||||
|
// GET /api/devices/:device_id/sidecar
|
||||||
|
func (h *SidecarHandler) GetSidecarConfig(c echo.Context) error {
|
||||||
|
deviceID, err := uuid.Parse(c.Param("device_id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||||
|
"error": "invalid device ID",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
pgDeviceID := pgtype.UUID{Bytes: deviceID, Valid: true}
|
||||||
|
|
||||||
|
// Get device info
|
||||||
|
device, err := h.db.GetDevice(ctx, pgDeviceID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{
|
||||||
|
"error": "device not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user info
|
||||||
|
userID := device.UserID.Bytes
|
||||||
|
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||||
|
|
||||||
|
// Get system config
|
||||||
|
opdsBaseURL, _ := h.db.GetSystemConfig(ctx, "opds_base_url")
|
||||||
|
apiBaseURL, _ := h.db.GetSystemConfig(ctx, "api_base_url")
|
||||||
|
|
||||||
|
// Generate URLs
|
||||||
|
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL.Value, deviceID.String())
|
||||||
|
syncAPIURL := fmt.Sprintf("%s/sync/kobo", apiBaseURL.Value)
|
||||||
|
|
||||||
|
// Get user's visible libraries with media items
|
||||||
|
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": "failed to fetch media items",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build books map (keyed by SHA256, fallback to UUID)
|
||||||
|
books := make(map[string]SidecarBook)
|
||||||
|
for _, item := range mediaItems {
|
||||||
|
bookUUID := uuid.UUID(item.ID.Bytes).String()
|
||||||
|
|
||||||
|
// Use SHA256 as key if available, otherwise use UUID
|
||||||
|
key := bookUUID
|
||||||
|
if item.FileSha256.Valid && item.FileSha256.String != "" {
|
||||||
|
key = item.FileSha256.String
|
||||||
|
}
|
||||||
|
|
||||||
|
availableFormats := []string{"epub"}
|
||||||
|
if item.MimeType.Valid {
|
||||||
|
if item.MimeType.String == "application/epub+zip" || item.MimeType.String == "application/octet-stream" {
|
||||||
|
availableFormats = append(availableFormats, "kepub")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
author := ""
|
||||||
|
if item.Author.Valid {
|
||||||
|
author = item.Author.String
|
||||||
|
}
|
||||||
|
|
||||||
|
books[key] = SidecarBook{
|
||||||
|
BookmannUUID: bookUUID,
|
||||||
|
Title: item.Title,
|
||||||
|
Author: author,
|
||||||
|
AvailableFormats: availableFormats,
|
||||||
|
SHA256: item.FileSha256.String,
|
||||||
|
FilePath: item.FilePath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get collections
|
||||||
|
collections, err := h.db.GetCollectionsByUser(ctx, pgUserID)
|
||||||
|
if err != nil {
|
||||||
|
// Non-fatal error, continue with empty collections
|
||||||
|
collections = []database.Collections{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build collections array
|
||||||
|
sidecarCollections := []SidecarCollection{}
|
||||||
|
for _, collection := range collections {
|
||||||
|
// Get collection items
|
||||||
|
collectionItems, err := h.db.GetCollectionItems(ctx, collection.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
bookIDs := make([]string, len(collectionItems))
|
||||||
|
for i, item := range collectionItems {
|
||||||
|
bookIDs[i] = uuid.UUID(item.MediaItemID.Bytes).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for device shelf mapping
|
||||||
|
shelfMapping := collection.Name
|
||||||
|
mapping, err := h.db.GetDeviceShelfMapping(ctx, database.GetDeviceShelfMappingParams{
|
||||||
|
DeviceID: pgDeviceID,
|
||||||
|
CollectionID: pgtype.UUID{Bytes: collection.ID.Bytes, Valid: true},
|
||||||
|
})
|
||||||
|
if err == nil && mapping.DeviceShelfName.Valid {
|
||||||
|
shelfMapping = mapping.DeviceShelfName.String
|
||||||
|
}
|
||||||
|
|
||||||
|
sidecarCollections = append(sidecarCollections, SidecarCollection{
|
||||||
|
Name: collection.Name,
|
||||||
|
ShelfMapping: shelfMapping,
|
||||||
|
BookIDs: bookIDs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build sidecar config
|
||||||
|
config := SidecarConfig{
|
||||||
|
Version: "1.0",
|
||||||
|
Bookmann: SidecarBookmannConfig{
|
||||||
|
OPDSCatalog: opdsCatalogURL,
|
||||||
|
SyncAPI: syncAPIURL,
|
||||||
|
OPDSBaseURL: opdsBaseURL.Value,
|
||||||
|
APIBaseURL: apiBaseURL.Value,
|
||||||
|
DeviceID: deviceID.String(),
|
||||||
|
DeviceToken: device.AuthToken,
|
||||||
|
},
|
||||||
|
Books: books,
|
||||||
|
Collections: sidecarCollections,
|
||||||
|
OPDSEnabled: true,
|
||||||
|
SidecarEnabled: true,
|
||||||
|
LastUpdated: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadSidecarConfig generates a .bookmann.json file for device setup
|
||||||
|
// GET /api/devices/:device_id/sidecar/download
|
||||||
|
func (h *SidecarHandler) DownloadSidecarConfig(c echo.Context) error {
|
||||||
|
deviceID, err := uuid.Parse(c.Param("device_id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||||
|
"error": "invalid device ID",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
pgDeviceID := pgtype.UUID{Bytes: deviceID, Valid: true}
|
||||||
|
|
||||||
|
// Get device info to get device name
|
||||||
|
device, err := h.db.GetDevice(ctx, pgDeviceID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{
|
||||||
|
"error": "device not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate sidecar config
|
||||||
|
var sidecarConfig SidecarConfig
|
||||||
|
|
||||||
|
// Reuse GetSidecarConfig logic by building config inline
|
||||||
|
userID := device.UserID.Bytes
|
||||||
|
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||||
|
|
||||||
|
// Get system config
|
||||||
|
opdsBaseURL, _ := h.db.GetSystemConfig(ctx, "opds_base_url")
|
||||||
|
apiBaseURL, _ := h.db.GetSystemConfig(ctx, "api_base_url")
|
||||||
|
|
||||||
|
// Generate URLs
|
||||||
|
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL.Value, deviceID.String())
|
||||||
|
syncAPIURL := fmt.Sprintf("%s/sync/kobo", apiBaseURL.Value)
|
||||||
|
|
||||||
|
// Get user's visible libraries with media items
|
||||||
|
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": "failed to fetch media items",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build books map
|
||||||
|
books := make(map[string]SidecarBook)
|
||||||
|
for _, item := range mediaItems {
|
||||||
|
bookUUID := uuid.UUID(item.ID.Bytes).String()
|
||||||
|
|
||||||
|
key := bookUUID
|
||||||
|
if item.FileSha256.Valid && item.FileSha256.String != "" {
|
||||||
|
key = item.FileSha256.String
|
||||||
|
}
|
||||||
|
|
||||||
|
availableFormats := []string{"epub"}
|
||||||
|
if item.MimeType.Valid {
|
||||||
|
if item.MimeType.String == "application/epub+zip" || item.MimeType.String == "application/octet-stream" {
|
||||||
|
availableFormats = append(availableFormats, "kepub")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
author := ""
|
||||||
|
if item.Author.Valid {
|
||||||
|
author = item.Author.String
|
||||||
|
}
|
||||||
|
|
||||||
|
books[key] = SidecarBook{
|
||||||
|
BookmannUUID: bookUUID,
|
||||||
|
Title: item.Title,
|
||||||
|
Author: author,
|
||||||
|
AvailableFormats: availableFormats,
|
||||||
|
SHA256: item.FileSha256.String,
|
||||||
|
FilePath: item.FilePath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get collections
|
||||||
|
collections, err := h.db.GetCollectionsByUser(ctx, pgUserID)
|
||||||
|
if err != nil {
|
||||||
|
collections = []database.Collections{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build collections array
|
||||||
|
sidecarCollections := []SidecarCollection{}
|
||||||
|
for _, collection := range collections {
|
||||||
|
collectionItems, err := h.db.GetCollectionItems(ctx, collection.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
bookIDs := make([]string, len(collectionItems))
|
||||||
|
for i, item := range collectionItems {
|
||||||
|
bookIDs[i] = uuid.UUID(item.MediaItemID.Bytes).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
shelfMapping := collection.Name
|
||||||
|
mapping, err := h.db.GetDeviceShelfMapping(ctx, database.GetDeviceShelfMappingParams{
|
||||||
|
DeviceID: pgDeviceID,
|
||||||
|
CollectionID: pgtype.UUID{Bytes: collection.ID.Bytes, Valid: true},
|
||||||
|
})
|
||||||
|
if err == nil && mapping.DeviceShelfName.Valid {
|
||||||
|
shelfMapping = mapping.DeviceShelfName.String
|
||||||
|
}
|
||||||
|
|
||||||
|
sidecarCollections = append(sidecarCollections, SidecarCollection{
|
||||||
|
Name: collection.Name,
|
||||||
|
ShelfMapping: shelfMapping,
|
||||||
|
BookIDs: bookIDs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sidecarConfig = SidecarConfig{
|
||||||
|
Version: "1.0",
|
||||||
|
Bookmann: SidecarBookmannConfig{
|
||||||
|
OPDSCatalog: opdsCatalogURL,
|
||||||
|
SyncAPI: syncAPIURL,
|
||||||
|
OPDSBaseURL: opdsBaseURL.Value,
|
||||||
|
APIBaseURL: apiBaseURL.Value,
|
||||||
|
DeviceID: deviceID.String(),
|
||||||
|
DeviceToken: device.AuthToken,
|
||||||
|
},
|
||||||
|
Books: books,
|
||||||
|
Collections: sidecarCollections,
|
||||||
|
OPDSEnabled: true,
|
||||||
|
SidecarEnabled: true,
|
||||||
|
LastUpdated: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal to JSON with pretty formatting
|
||||||
|
configJSON, err := json.MarshalIndent(sidecarConfig, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": "failed to generate config",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers for file download
|
||||||
|
filename := fmt.Sprintf("%s.bookmann.json", sanitizeFilename(device.DeviceName))
|
||||||
|
c.Response().Header().Set("Content-Type", "application/json")
|
||||||
|
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
|
|
||||||
|
return c.JSONBlob(http.StatusOK, configJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSystemConfiguration returns system-wide configuration
|
||||||
|
// GET /api/system/config
|
||||||
|
func (h *SidecarHandler) GetSystemConfiguration(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
|
// Get all system config
|
||||||
|
configs, err := h.db.GetAllSystemConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": "failed to fetch system config",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build config map
|
||||||
|
result := make(map[string]string)
|
||||||
|
for _, config := range configs {
|
||||||
|
result[config.Key] = config.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSystemConfiguration updates system-wide configuration
|
||||||
|
// PUT /api/system/config
|
||||||
|
func (h *SidecarHandler) UpdateSystemConfiguration(c echo.Context) error {
|
||||||
|
user := c.Get("user")
|
||||||
|
if user == nil {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||||
|
"error": "unauthorized",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfo := user.(database.Users)
|
||||||
|
if userInfo.Role != "admin" {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{
|
||||||
|
"error": "admin access required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req map[string]string
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||||
|
"error": "invalid request format",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
pgUserID := pgtype.UUID{Bytes: userInfo.ID.Bytes, Valid: true}
|
||||||
|
|
||||||
|
// Update each config value
|
||||||
|
for key, value := range req {
|
||||||
|
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
|
||||||
|
Key: key,
|
||||||
|
Value: value,
|
||||||
|
UpdatedBy: pgUserID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": fmt.Sprintf("failed to update config key: %s", key),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{
|
||||||
|
"status": "success",
|
||||||
|
"message": "System configuration updated",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeFilename(name string) string {
|
||||||
|
// Simple sanitization - replace problematic characters
|
||||||
|
sanitized := name
|
||||||
|
for _, ch := range []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|"} {
|
||||||
|
sanitized = sanitizeAll(sanitized, ch, "_")
|
||||||
|
}
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeAll(s string, old string, new string) string {
|
||||||
|
result := ""
|
||||||
|
for _, ch := range s {
|
||||||
|
c := string(ch)
|
||||||
|
if c == old {
|
||||||
|
result += new
|
||||||
|
} else {
|
||||||
|
result += c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
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",
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user