Files
bookhoard/internal/handlers/opds.go
T
john-okeefe 209e9f2a3c feat: implement relative path storage and URL resolution for media files
- Add libraryService dependency to CollectionHandler and OPDSHandler for centralized path resolution
- Create internal/utils/mediaurl.go with ResolveMediaURL() function as single source of truth
- Update GetMediaItem and ListMediaItems handlers to return resolved URLs in API responses
- Update collection handlers (GetCollection, TestRules, PreviewCollection) to use resolved cover URLs
- Update progress handler (GetAllProgress) to use resolved cover URLs
- Add library_id to GetCollectionItems SQL query to enable URL resolution
- Refactor media scanner to store relative paths instead of absolute filesystem paths
- Add ResolveMediaPath() to LibraryService for resolving relative paths to absolute paths
- Add ServeFile endpoint at /uploads/library-:id/* for authenticated file serving
- Add MimeTypes map to library_service.go for consistent MIME type handling
- Update DownloadBook handler to use resolved filesystem paths
- Add getRelativePath() helper to MediaScanner for converting absolute to relative paths
- Use strings.EqualFold for case-insensitive path comparisons in zip extraction

This change enables the application to work with relative paths stored in the
database, making it portable across different server environments while
maintaining backward compatibility with existing absolute paths.
2026-02-27 16:51:44 -05:00

799 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 {
converted, err := h.conversionService.ConvertEPUBToKEPUB(c.Request().Context(), pgtype.UUID{Bytes: bookUUID, Valid: true}, mediaItem.FilePath)
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 = 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-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,
})
}