Files
bookhoard/internal/handlers/opds.go
T
john-okeefe 48af5d3e14 feat(opds): always send X-Bookhoard-SHA256 on native EPUB downloads
DownloadBook populated fileSha256 only for the kepub and pdf format
branches, so the default EPUB download never emitted the
X-Bookhoard-SHA256 response header - the hash was only available in
the feed metadata, not on the download response itself.

Populate it from mediaItem.FileSha256 in the default branch so every
download response carries the canonical primary-format hash. Clients
that capture response headers at download time now learn the hash
regardless of which format they requested.
2026-08-14 08:26:14 -04:00

971 lines
30 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)
}
settings *database.SettingsRegistry
}
// SetSettings wires the tunable settings registry (OPDS page size).
func (h *OPDSHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
// opdsDefaultPageSize returns the configured default page size (50 if unset).
func (h *OPDSHandler) opdsDefaultPageSize() int {
if h.settings != nil {
return h.settings.OpdsDefaultPageSize()
}
return 50
}
// opdsMaxPageSize returns the configured maximum page size (200 if unset).
func (h *OPDSHandler) opdsMaxPageSize() int {
if h.settings != nil {
return h.settings.OpdsMaxPageSize()
}
return 200
}
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 URL from system config with request-derived fallback
func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) {
var dbBaseURL string
if config, err := h.db.GetSystemConfig(c.Request().Context(), "base_url"); err == nil {
dbBaseURL = config.Value
}
baseURL := deriveBaseURL(c, dbBaseURL)
opdsBaseURL := baseURL + "/opds"
return baseURL, opdsBaseURL, nil
}
func (h *OPDSHandler) getAuthToken(c *echo.Context) string {
token := c.QueryParam("token")
if token == "" {
token = strings.TrimPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
}
return token
}
func appendToken(url, token string) string {
if token == "" {
return url
}
if strings.Contains(url, "?") {
return url + "&token=" + token
}
return url + "?token=" + token
}
// catalogMediaType is the OPDS media type for an acquisition catalog feed.
const catalogMediaType = "application/atom+xml;profile=opds-catalog;kind=acquisition"
// addCatalogPaginationLinks adds OPDS pagination links (self, start, first,
// previous, next, last) and OpenSearch paging metadata (totalResults,
// itemsPerPage, startIndex) to a feed based on the current page position.
// catalogBase is the device catalog URL without query parameters. The token
// (device auth) is appended to every generated link.
func addCatalogPaginationLinks(feed *opds.Feed, catalogBase string, pageNum, perPageNum, totalItems int, token string) {
totalPages := 0
if totalItems > 0 {
totalPages = (totalItems + perPageNum - 1) / perPageNum
}
startIdx := (pageNum - 1) * perPageNum
pagedURL := func(page int) string {
return appendToken(fmt.Sprintf("%s?page=%d&per_page=%d", catalogBase, page, perPageNum), token)
}
// self reflects the current page; start/first point to the first page
feed.AddLink(pagedURL(pageNum), catalogMediaType, "self")
feed.AddLink(pagedURL(1), catalogMediaType, "start")
feed.AddLink(pagedURL(1), catalogMediaType, "first")
if totalPages > 0 {
feed.AddLink(pagedURL(totalPages), catalogMediaType, "last")
}
if pageNum > 1 {
feed.AddLink(pagedURL(pageNum-1), catalogMediaType, "previous")
}
if pageNum < totalPages {
feed.AddLink(pagedURL(pageNum+1), catalogMediaType, "next")
}
feed.SetPagination(totalItems, perPageNum, startIdx+1)
}
// resolveMimeType returns the mime type for a media item, preferring the stored
// mime_type, then format_mimetype, and finally falling back to EPUB.
func resolveMimeType(mime, formatMime pgtype.Text) string {
if mime.Valid && mime.String != "" {
return mime.String
}
if formatMime.Valid && formatMime.String != "" {
return formatMime.String
}
return "application/epub+zip"
}
// isComicArchive reports whether a format group represents a comic/manga
// archive (cbz/cbr/cb7/cbt). Comic archives are served in their native format
// and should not be offered as EPUB/KEPUB/PDF conversions.
func isComicArchive(formatGroup string) bool {
return strings.EqualFold(formatGroup, "comic_archive")
}
// formatLabelFromPath derives a short format label (e.g. "epub", "cbz") from a
// file path's extension, defaulting to "epub" when it cannot be determined.
func formatLabelFromPath(path string) string {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".epub":
return "epub"
case ".pdf":
return "pdf"
case ".cbz":
return "cbz"
case ".cbr":
return "cbr"
case ".cb7":
return "cb7"
case ".cbt":
return "cbt"
case ".mobi":
return "mobi"
case ".azw", ".azw3":
return "azw3"
case ".txt":
return "txt"
case "":
return "epub"
default:
return strings.TrimPrefix(ext, ".")
}
}
// 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 := h.opdsDefaultPageSize()
maxPerPage := h.opdsMaxPageSize()
if perPage != "" {
if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= maxPerPage {
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",
)
// Feed links, including OPDS pagination links (first/previous/next/last) and
// OpenSearch paging metadata (totalResults/itemsPerPage/startIndex).
token := h.getAuthToken(c)
catalogBase := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
addCatalogPaginationLinks(feed, catalogBase, pageNum, perPageNum, totalItems, token)
// OpenSearch: the search link points to an OpenSearch description document
// (served by the same /search endpoint when no query is supplied) so that
// OPDS clients like KOReader can discover how to formulate search requests.
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search", opdsBaseURL, deviceID), token)
feed.AddLink(searchURL, "application/opensearchdescription+xml", "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 link using the item's real mime type
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
// Only offer reflowable conversions (kepub/pdf) for ebooks; comic
// archives are served as-is in their native format.
if !isComicArchive(item.FormatGroup) {
if device.DeviceType == "kobo" {
kepubURL := downloadURL + "&format=kepub"
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
}
pdfURL := downloadURL + "&format=pdf"
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 && 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.
//
// When no "q" query parameter is supplied it returns an OpenSearch description
// document (application/opensearchdescription+xml) so that OPDS clients such as
// KOReader can discover the search URL template (which contains the
// {searchTerms} placeholder). When "q" is supplied it returns an OPDS
// acquisition feed of matching books.
func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
deviceID := c.Param("deviceId")
query := c.QueryParam("q")
// 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"))
}
token := h.getAuthToken(c)
// No query: serve the OpenSearch description document so clients can learn
// the search template (contains the {searchTerms} placeholder).
if query == "" {
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID), token)
desc := opds.NewSearchDescription(
"Bookhoard",
"Search the Bookhoard library",
searchURL,
)
xmlString, err := desc.GenerateXMLString()
if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate search description"))
}
c.Response().Header().Set("Content-Type", "application/opensearchdescription+xml")
return c.String(http.StatusOK, xmlString)
}
// 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 := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
feed.AddLink(catalogURL, catalogMediaType, "start")
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q=%s", opdsBaseURL, deviceID, query), token)
feed.AddLink(searchURL, catalogMediaType, "self")
// OpenSearch paging metadata (search results are a single page)
feed.SetPagination(len(allItems), len(allItems), 1)
// 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 := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
// Only offer kepub conversion for ebooks; comic archives are served
// as-is in their native format.
if !isComicArchive(item.FormatGroup) && device.DeviceType == "kobo" {
kepubURL := downloadURL + "&format=kepub"
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 && 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 lib.ID.Bytes == 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
}
// Always expose the primary content hash so clients (e.g. the koreader
// plugin) learn the canonical SHA-256 from the download response itself,
// not just from the feed metadata.
if mediaItem.FileSha256.Valid {
fileSha256 = mediaItem.FileSha256.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 lib.ID.Bytes == 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/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 lib.ID.Bytes == 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 the primary/native format (always available if media item exists)
fileSize := int64(0)
if mediaItem.FileSize.Valid {
fileSize = mediaItem.FileSize.Int64
}
formatList = append(formatList, FormatInfo{
FormatType: formatLabelFromPath(mediaItem.FilePath),
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/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,
})
}