Update all handler functions to use *echo.Context (pointer) instead of echo.Context (value) as required by Echo v5. Changes across all handler files: - analytics.go: Update handler signatures - auth.go: Update authentication handler signatures - book_matching.go: Update matching handler signatures - collections.go: Update collection handler signatures - collections_preview_test.go: Update test signatures - commonhandlers.go: Update common handler signatures - conflicts.go: Update conflict handler signatures - context.go: Update context handler signatures - dashboard.go: Update dashboard handler signatures - devices.go: Update device handler signatures - jobs.go: Update job handler signatures - kobo.go: Update Kobo handler signatures - koreader.go: Update Koreader handler signatures - library.go: Update library handler signatures - matching.go: Update matching handler signatures - media.go: Update media handler signatures - opds.go: Update OPDS handler signatures - progress.go: Update progress handler signatures - queue.go: Update queue handler signatures - refresh_token.go: Update token handler signatures - scanner.go: Update scanner handler signatures - sidecar.go: Update sidecar handler signatures - sync.go: Update sync handler signatures - system_settings.go: Update settings handler signatures - websocket.go: Update WebSocket handler signatures All handlers now properly implement Echo v5's pointer-based context pattern. This change is necessary for type safety and compatibility with Echo v5's improved context handling and WebSocket support.
810 lines
24 KiB
Go
810 lines
24 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/opds"
|
|
"bookhoard/internal/services"
|
|
"context"
|
|
"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/v5"
|
|
)
|
|
|
|
type OPDSHandler struct {
|
|
db *database.Queries
|
|
libraryService *services.LibraryService
|
|
conversionService interface {
|
|
ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*services.ConvertedKEPUB, error)
|
|
}
|
|
}
|
|
|
|
func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryService, conversionService interface {
|
|
ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*services.ConvertedKEPUB, error)
|
|
}) *OPDSHandler {
|
|
return &OPDSHandler{
|
|
db: db,
|
|
libraryService: libraryService,
|
|
conversionService: conversionService,
|
|
}
|
|
}
|
|
|
|
// 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.EqualFold(item.FormatGroup, 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),
|
|
"Bookhoard 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("bookhoard: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("bookhoard: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 (middleware guarantees valid UUIDs)
|
|
deviceUUID, _ := uuid.Parse(deviceID)
|
|
bookUUID, _ := uuid.Parse(bookID)
|
|
|
|
// 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" {
|
|
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 {
|
|
if h.conversionService != nil {
|
|
resolvedPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
|
}
|
|
converted, err := h.conversionService.ConvertEPUBToKEPUB(c.Request().Context(), pgtype.UUID{Bytes: bookUUID, Valid: true}, resolvedPath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("KEPUB conversion failed: %v", err)})
|
|
}
|
|
filePath = converted.Path
|
|
fileSha256 = converted.SHA256
|
|
mimeType = "application/vnd.kobo+xml+zip"
|
|
} else {
|
|
filePath, err = h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
|
}
|
|
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
|
|
var err error
|
|
filePath, err = h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
|
}
|
|
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-Bookhoard-UUID", bookUUID.String())
|
|
|
|
if format == "kepub" && fileSha256 != "" {
|
|
c.Response().Header().Set("X-Bookhoard-KEPUB-SHA256", fileSha256)
|
|
} else if fileSha256 != "" {
|
|
c.Response().Header().Set("X-Bookhoard-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 (middleware guarantees valid UUIDs)
|
|
deviceUUID, _ := uuid.Parse(deviceID)
|
|
bookUUID, _ := uuid.Parse(bookID)
|
|
|
|
// 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
|
|
|
|
// Resolve relative path using library service
|
|
fullPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, coverPath)
|
|
if err != nil {
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
// Check if file exists
|
|
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
// Open file
|
|
file, err := os.Open(fullPath)
|
|
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(fullPath))
|
|
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 (middleware guarantees valid UUID)
|
|
deviceUUID, _ := uuid.Parse(deviceID)
|
|
|
|
// 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),
|
|
"Bookhoard 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 (middleware guarantees valid UUIDs)
|
|
deviceUUID, _ := uuid.Parse(deviceID)
|
|
bookUUID, _ := uuid.Parse(bookID)
|
|
|
|
// 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,
|
|
})
|
|
}
|