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 } } // 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, }) }