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,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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user