Multi-file book ingest: a directory of ≥2 audio files (recursively —
disc-part folders like CD1/Disc 2 merge upward, siblings never merge)
ingests as ONE book whose file_path is the book-root directory; a zip
of audio tracks is its own book (file_path = the archive). Both write
the documented chapters abstraction {index,title,start,end} into
audio_metadata and add additive keys: layout ("folder"|"zip") and a
tracks[] manifest (per-track path, global-seconds start/end, duration,
size). Pre-grouping one-track strays under a book root are absorbed on
rescan (annotations cascade). Rescans take a stat-only fast path
(track count + total size + known durations unchanged → no re-probe —
a 1189-track book must not re-ffprobe every auto-scan); a zero-duration
book counts as changed so a ffprobe-equipped rescan fills durations in.
Folder-book roots count as present in the archive lifecycle pass, and
per-item rescan (RescanMediaItem) routes multi-file books through a
book-level refresh — the generic updater would read the root/zip as if
it were the book file and clobber format_group with "unknown".
Serving: GET /api/media-items/:id/chapters gains additive layout +
tracks keys (urls → the new endpoint). New GET
/api/media-items/:id/audio/:trackIndex streams one track — folder
tracks served from the real file (full Range via ServeContent), zip
tracks via a reopen-and-skip ReadSeeker over the entry (deflate has no
random access; acceptable at audio bitrates; stored entries stay
section readers). Both endpoints gate on library visibility (403 on
hidden libraries — family-lens rule). ServeFile and OPDS DownloadBook
refuse directory paths (folder books have no single-file download).
Web player (next commit) consumes the manifest; progress/bookmarks/
deep-links keep their global-seconds coordinates, so the server-side
progress model is untouched. sync learns FormatGroupAudiobook
(percentage passthrough in ConvertProgress); detectFormatType stops
reporting "unknown" for audio.
Metadata ingestion (Batch B) — read everything available, degrade
gracefully, documented precedence sidecars → folder structure →
embedded tags → filename: ffprobe tags are now a case-insensitive
lookup map (freeform keys pass through verbatim); composer fills
narrators (Audible's own convention, kept as ONE string — no reliable
multi-narrator delimiter); grouping + Audible freeform atoms
(SERIES/SERIES-PART/SUBTITLE/ASIN/ABRIDGED) fill series/index/
subtitle-join/ASIN/abridged (explicit sidecar abridged-false is
guarded); date/genre fall back to tags; album/album_artist/artist
fill title/author. Genre persists at create for all formats.
Embedded cover art (mp3 APIC, m4b covr, flac picture) extracts via one
optional ffmpeg call as a "<book>.cover.jpg" sibling — sidecar covers
win. CUE sheets (FILE-matched, INDEX 01 at 75fps) are the lowest-
priority chapter source under sidecars and embedded m4b chapters. A
lone m3u/m3u8 in a book root imposes playlist ordering + #EXTINF
names; (disc, track) tags reorder tracks when coverage is complete.
extractMetadata's default branch no longer pre-fills the filename as
title (tags must outrank it) — final filename fallbacks now exist on
BOTH the create and rescan paths (TestRescanKeepsFilenameTitle pins
that rescans cannot blank a title); tagless zip books take the zip's
own name, not their parent folder's.
Accepted containers widen to .m4b .m4a .mp3 .flac .zip .opus .ogg .oga
.aac .wav with real media types registered (audio/ogg per RFC 7845 for
Opus-in-Ogg) — all browser-playable; .wma deliberately excluded.
Tests: unit tests for CUE parsing, playlist hints, tag fallbacks
(synthetic ffprobe reports — ffmpeg cannot write Audible freeform
atoms), ordinal parsing, tag ordering, natural sort, grouping rules;
integration suite (11 tests) over the live dev DB: folder/zip ingest +
idempotency, stray absorption, chapters manifest, per-track streaming
with Range, hidden-library 403s, embedded covers + sidecar-wins, CUE
chapters, embedded m4b chapters, playlist ordering, tag fallbacks, all
five new containers with content-type assertions, rescan title pin.
Fixture media is real committed-format binary content living untracked
under internal/services/testdata/ (gitignored by policy; recipes in
docs/contributing/development.md → Media fixtures). Test image now
ships ffmpeg to match the runtime's audio toolchain.
1276 lines
41 KiB
Go
1276 lines
41 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, listRowsToOpdsInputs(allItems), baseURL, opdsBaseURL, token, acquisitionEntryOpts{includePdfAlternate: true})
|
|
|
|
// 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)
|
|
}
|
|
|
|
// opdsEntryInput is the narrow field set an OPDS acquisition entry
|
|
// needs, normalized across the different sqlc row shapes (library
|
|
// listing rows vs search rows). Deliberately NOT a row converter: it is
|
|
// the row subset the feed actually renders.
|
|
type opdsEntryInput struct {
|
|
id pgtype.UUID
|
|
mediaItemID pgtype.UUID
|
|
title string
|
|
author pgtype.Text
|
|
description pgtype.Text
|
|
updatedAt pgtype.Timestamptz
|
|
mimeType pgtype.Text
|
|
formatMimetype pgtype.Text
|
|
formatGroup string
|
|
libraryName string
|
|
fileSha256 pgtype.Text
|
|
}
|
|
|
|
// acquisitionEntryOpts carries the two ways acquisition feeds
|
|
// legitimately differ:
|
|
// - labelLibraries: unscoped search labels each entry with its library
|
|
// so duplicate copies are distinguishable; scoped catalogs don't.
|
|
// - includePdfAlternate: catalogs offer the PDF alternate for
|
|
// reflowable books; the search feed historically did not. Kept
|
|
// as-is until someone decides search should match — that would be a
|
|
// behavior change, not a refactor.
|
|
type acquisitionEntryOpts struct {
|
|
labelLibraries bool
|
|
includePdfAlternate bool
|
|
}
|
|
|
|
// addAcquisitionEntries fills an acquisition feed with book entries.
|
|
// Shared body of the all-books, per-library, and search catalogs.
|
|
func (h *OPDSHandler) addAcquisitionEntries(ctx context.Context, feed *opds.Feed, device database.Devices, deviceID string, items []opdsEntryInput, baseURL, opdsBaseURL, token string, opts acquisitionEntryOpts) {
|
|
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)
|
|
}
|
|
|
|
// Global (unscoped) search: label which library each copy lives in
|
|
// so duplicate copies of the same book are distinguishable.
|
|
if opts.labelLibraries && item.libraryName != "" {
|
|
entry.AddCategory(baseURL+"/libraries", item.libraryName)
|
|
if !item.description.Valid || item.description.String == "" {
|
|
entry.SetSummary("Library: " + item.libraryName)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
if opts.includePdfAlternate {
|
|
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, item.mediaItemID)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// listRowsToOpdsInputs normalizes library-listing rows for the feed
|
|
// builder (the search handler fills opdsEntryInput from its own row
|
|
// type — the field subset, not a full row copy).
|
|
func listRowsToOpdsInputs(rows []database.ListMediaItemsByLibraryRow) []opdsEntryInput {
|
|
inputs := make([]opdsEntryInput, 0, len(rows))
|
|
for _, r := range rows {
|
|
inputs = append(inputs, opdsEntryInput{
|
|
id: r.ID,
|
|
mediaItemID: pgtype.UUID{Bytes: r.ID.Bytes, Valid: true},
|
|
title: r.Title,
|
|
author: r.Author,
|
|
description: r.Description,
|
|
updatedAt: r.UpdatedAt,
|
|
mimeType: r.MimeType,
|
|
formatMimetype: r.FormatMimetype,
|
|
formatGroup: r.FormatGroup,
|
|
libraryName: r.LibraryName,
|
|
fileSha256: r.FileSha256,
|
|
})
|
|
}
|
|
return inputs
|
|
}
|
|
|
|
// 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, listRowsToOpdsInputs(items), baseURL, opdsBaseURL, token, acquisitionEntryOpts{includePdfAlternate: true})
|
|
|
|
// 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). Unscoped searches label each entry
|
|
// with its library; scoped ones don't. The PDF alternate stays off in
|
|
// search (historical — see acquisitionEntryOpts).
|
|
inputs := make([]opdsEntryInput, 0, len(allItems))
|
|
for _, item := range allItems {
|
|
inputs = append(inputs, opdsEntryInput{
|
|
id: item.ID,
|
|
mediaItemID: item.ID,
|
|
title: item.Title,
|
|
author: item.Author,
|
|
description: item.Description,
|
|
updatedAt: item.UpdatedAt,
|
|
mimeType: item.MimeType,
|
|
formatGroup: item.FormatGroup,
|
|
libraryName: item.LibraryName,
|
|
fileSha256: item.FileSha256,
|
|
})
|
|
}
|
|
h.addAcquisitionEntries(c.Request().Context(), feed, device, deviceID, inputs, baseURL, opdsBaseURL, token,
|
|
acquisitionEntryOpts{labelLibraries: !scopedLibraryID.Valid})
|
|
|
|
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
|
|
fileInfo, err := os.Stat(filePath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
|
}
|
|
// Folder-of-tracks audiobooks point at a book root DIRECTORY: there
|
|
// is no single file to stream for OPDS clients (a directory fd reads
|
|
// EISDIR). Zip books are real files and keep working.
|
|
if fileInfo.IsDir() {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "multi-file books have no single download"})
|
|
}
|
|
|
|
// 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 already statted above)
|
|
|
|
// 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,
|
|
})
|
|
}
|