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 URL 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 := baseURL.Value + "/opds" return baseURL.Value, opdsBaseURL, 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 && 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 && 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 } } // 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/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 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 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, }) }