Update the DownloadBook function to properly resolve media file paths using the LibraryService.ResolveMediaPath method instead of directly accessing the FilePath field. This ensures correct file resolution after the migration to relative path storage. The change affects three code paths in the download handler: - KEPUB conversion path - Direct file serve path (non-EPUB with conversion service) - Default EPUB path Error handling added to return 404 when path resolution fails, preventing potential errors when accessing non-existent files. This fixes potential file access issues after the relative path storage implementation.
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/v4"
|
|
)
|
|
|
|
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,
|
|
})
|
|
}
|