The audit showed only 5 of ~11 time-display surfaces converted to the user's timezone; everything else rendered pgx-decoded UTC. The server owns conversion — clients (web/app/KOReader) receive display-ready strings or machine-readable instants and never convert. - Progress page: LastUpdated formatted in the requesting user's tz (was raw UTC). - Analytics: date-range defaults, day buckets, most-active-day, device last-sync, and popular-book last-read all computed in the user's tz — evening sessions no longer land on the next UTC day. Also fixed the end-date boundary: the query bounds with <= and the end bound was midnight, so the end date's own sessions (including 'today') never appeared. - Queue page: new created_at_display field (admin tz) rendered by the template; created_at stays RFC3339 for API consumers. - Admin hash-conflicts SSR bridge: detected date rendered in the admin's tz. - OPDS: .UTC() before the Z-suffixed feed timestamps (honesty fix — correct today only because the session zone happens to be UTC). - KOReader annotation datetimes deliberately untouched (device owns its display).
1248 lines
39 KiB
Go
1248 lines
39 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"
|
|
|
|
// navFeedMediaType is the OPDS media type for a navigation catalog feed.
|
|
const navFeedMediaType = "application/atom+xml;profile=opds-catalog;kind=navigation"
|
|
|
|
// 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 serves the OPDS root feed for a device.
|
|
//
|
|
// By default it returns a navigation feed: an "All Books" entry (the flat
|
|
// cross-library catalog) followed by one folder entry per visible library.
|
|
// OPDS clients render navigation entries as folders, so each library's
|
|
// books — and its library-scoped search — live under their own node instead
|
|
// of interleaving duplicate copies of the same book in one flat list.
|
|
// Passing ?all=1 serves the flat cross-library acquisition feed directly,
|
|
// preserving the pre-folders behavior.
|
|
func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|
if c.QueryParam("all") == "1" {
|
|
return h.serveAllBooksCatalog(c)
|
|
}
|
|
|
|
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
|
|
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
|
|
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"))
|
|
}
|
|
|
|
token := h.getAuthToken(c)
|
|
|
|
feed := opds.NewFeed(
|
|
fmt.Sprintf("urn:uuid:%s", deviceID),
|
|
"Bookhoard Library",
|
|
)
|
|
|
|
// OpenSearch discovery: the root search stays global (all libraries).
|
|
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID), token)
|
|
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
|
|
|
|
updated := time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
|
|
|
// "All Books" first: the flat cross-library catalog (?all=1).
|
|
allBooks := opds.NewEntry(
|
|
fmt.Sprintf("urn:uuid:%s-all-books", deviceID),
|
|
"All Books",
|
|
"",
|
|
updated,
|
|
)
|
|
allBooks.AddLink(appendToken(fmt.Sprintf("%s/devices/%s/catalog?all=1", opdsBaseURL, deviceID), token), catalogMediaType, "subsection")
|
|
allBooks.SetSummary("Browse every book across all your libraries")
|
|
feed.AddEntry(allBooks)
|
|
|
|
// One folder per visible library, with live book counts.
|
|
counts := make(map[pgtype.UUID]int64)
|
|
if countRows, cerr := h.db.GetVisibleLibraryMediaCounts(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}); cerr == nil {
|
|
for _, row := range countRows {
|
|
counts[row.ID] = row.MediaCount
|
|
}
|
|
}
|
|
|
|
for _, lib := range libraries {
|
|
libUUID := uuid.UUID(lib.ID.Bytes).String()
|
|
title := lib.Name
|
|
if n, ok := counts[lib.ID]; ok {
|
|
title = fmt.Sprintf("%s (%d)", lib.Name, n)
|
|
}
|
|
entry := opds.NewEntry(
|
|
fmt.Sprintf("urn:uuid:%s-library-%s", deviceID, libUUID),
|
|
title,
|
|
"",
|
|
updated,
|
|
)
|
|
entry.AddLink(appendToken(fmt.Sprintf("%s/devices/%s/library/%s/catalog", opdsBaseURL, deviceID, libUUID), token), catalogMediaType, "subsection")
|
|
entry.SetSummary("Browse books in the " + lib.Name + " library")
|
|
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", navFeedMediaType)
|
|
return c.String(http.StatusOK, xmlString)
|
|
}
|
|
|
|
// serveAllBooksCatalog is the original flat cross-library acquisition feed:
|
|
// every book from every visible library, paginated as one list.
|
|
func (h *OPDSHandler) serveAllBooksCatalog(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
|
|
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
|
|
h.addAcquisitionEntries(c.Request().Context(), feed, device, deviceID, allItems, baseURL, opdsBaseURL, token)
|
|
|
|
// 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", catalogMediaType)
|
|
return c.String(http.StatusOK, xmlString)
|
|
}
|
|
|
|
// addAcquisitionEntries fills an acquisition feed with book entries for the
|
|
// given items. Shared body of the all-books and per-library catalogs.
|
|
func (h *OPDSHandler) addAcquisitionEntries(ctx context.Context, feed *opds.Feed, device database.Devices, deviceID string, items []database.ListMediaItemsByLibraryRow, baseURL, opdsBaseURL, token string) {
|
|
userUUID := uuid.UUID(device.UserID.Bytes)
|
|
for _, item := range items {
|
|
bookUUID := uuid.UUID(item.ID.Bytes).String()
|
|
title := item.Title
|
|
author := ""
|
|
if item.Author.Valid {
|
|
author = item.Author.String
|
|
}
|
|
|
|
updated := item.UpdatedAt.Time.UTC().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(ctx, 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)
|
|
}
|
|
}
|
|
|
|
// GetLibraryCatalog serves an acquisition feed scoped to one of the device
|
|
// owner's visible libraries. Its search link is library-scoped: OPDS clients
|
|
// only substitute {searchTerms}, so opening search from inside this folder
|
|
// searches within this library only.
|
|
func (h *OPDSHandler) GetLibraryCatalog(c *echo.Context) error {
|
|
deviceID := c.Param("deviceId")
|
|
libraryID := c.Param("libraryId")
|
|
|
|
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 IDs
|
|
deviceUUID, err := uuid.Parse(deviceID)
|
|
if err != nil {
|
|
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid device ID"))
|
|
}
|
|
libUUID, err := uuid.Parse(libraryID)
|
|
if err != nil {
|
|
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid library 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"))
|
|
}
|
|
|
|
// The library must be visible to the device owner.
|
|
userID := device.UserID.Bytes
|
|
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"))
|
|
}
|
|
var library *database.GetUserVisibleLibrariesRow
|
|
for i := range libraries {
|
|
if libraries[i].ID.Bytes == libUUID {
|
|
library = &libraries[i]
|
|
break
|
|
}
|
|
}
|
|
if library == nil {
|
|
return c.XML(http.StatusForbidden, opds.NewErrorFeed("Library not accessible"))
|
|
}
|
|
|
|
// Get media items
|
|
items, err := h.db.ListMediaItemsByLibrary(c.Request().Context(), library.ID)
|
|
if err != nil {
|
|
items = []database.ListMediaItemsByLibraryRow{}
|
|
}
|
|
|
|
// Filter by format if specified
|
|
if includeFormat != "" {
|
|
filteredItems := []database.ListMediaItemsByLibraryRow{}
|
|
for _, item := range items {
|
|
if strings.EqualFold(item.FormatGroup, includeFormat) {
|
|
filteredItems = append(filteredItems, item)
|
|
}
|
|
}
|
|
items = filteredItems
|
|
}
|
|
|
|
// Pagination
|
|
totalItems := len(items)
|
|
startIdx := (pageNum - 1) * perPageNum
|
|
endIdx := startIdx + perPageNum
|
|
|
|
if startIdx >= totalItems {
|
|
items = []database.ListMediaItemsByLibraryRow{}
|
|
} else if endIdx > totalItems {
|
|
items = items[startIdx:]
|
|
} else {
|
|
items = items[startIdx:endIdx]
|
|
}
|
|
|
|
// Create OPDS feed
|
|
feed := opds.NewFeed(
|
|
fmt.Sprintf("urn:uuid:%s-library-%s", deviceID, libUUID),
|
|
library.Name,
|
|
)
|
|
|
|
token := h.getAuthToken(c)
|
|
catalogBase := fmt.Sprintf("%s/devices/%s/library/%s/catalog", opdsBaseURL, deviceID, libUUID)
|
|
addCatalogPaginationLinks(feed, catalogBase, pageNum, perPageNum, totalItems, token)
|
|
|
|
// Library-scoped search discovery: the template pins this library.
|
|
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}&library_id=%s", opdsBaseURL, deviceID, libUUID), token)
|
|
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
|
|
|
|
// Up-link back to the root navigation feed.
|
|
rootURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
|
|
feed.AddLink(rootURL, navFeedMediaType, "up")
|
|
|
|
// Add entries
|
|
h.addAcquisitionEntries(c.Request().Context(), feed, device, deviceID, items, baseURL, opdsBaseURL, token)
|
|
|
|
// 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", catalogMediaType)
|
|
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
|
|
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"))
|
|
}
|
|
|
|
// Optional library scope: library folder feeds advertise their own search
|
|
// link with this parameter, so opening search from inside a library
|
|
// folder searches within that library only.
|
|
var scopedLibraryID pgtype.UUID
|
|
scopedLibraryName := ""
|
|
if libParam := c.QueryParam("library_id"); libParam != "" {
|
|
parsed, perr := uuid.Parse(libParam)
|
|
if perr != nil {
|
|
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid library ID"))
|
|
}
|
|
for i := range libraries {
|
|
if libraries[i].ID.Bytes == parsed {
|
|
scopedLibraryID = libraries[i].ID
|
|
scopedLibraryName = libraries[i].Name
|
|
break
|
|
}
|
|
}
|
|
if !scopedLibraryID.Valid {
|
|
return c.XML(http.StatusForbidden, opds.NewErrorFeed("Library not accessible"))
|
|
}
|
|
}
|
|
|
|
token := h.getAuthToken(c)
|
|
|
|
// No query: serve the OpenSearch description document so clients can learn
|
|
// the search URL template (contains the {searchTerms} placeholder).
|
|
if query == "" {
|
|
template := fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID)
|
|
shortName := "Bookhoard"
|
|
description := "Search the Bookhoard library"
|
|
if scopedLibraryID.Valid {
|
|
template += "&library_id=" + scopedLibraryID.String()
|
|
shortName = "Bookhoard: " + scopedLibraryName
|
|
description = "Search the " + scopedLibraryName + " library"
|
|
}
|
|
searchURL := appendToken(template, token)
|
|
desc := opds.NewSearchDescription(
|
|
shortName,
|
|
description,
|
|
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},
|
|
LibraryID: scopedLibraryID,
|
|
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.UTC().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)
|
|
}
|
|
|
|
// Global (unscoped) search: label which library each copy lives in so
|
|
// duplicate copies of the same book are distinguishable.
|
|
if !scopedLibraryID.Valid && item.LibraryName != "" {
|
|
entry.AddCategory(baseURL+"/libraries", item.LibraryName)
|
|
if !item.Description.Valid || item.Description.String == "" {
|
|
entry.SetSummary("Library: " + item.LibraryName)
|
|
}
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|