refactor(opds): one acquisition-entry builder for the catalog and search feeds

The search feed carried a ~55-line inline copy of the acquisition-entry
skeleton, over a DIFFERENT sqlc row type (SearchMediaItemsRow vs
ListMediaItemsByLibraryRow) — the row-type split is why it stayed a
twin. The shared builder now takes opdsEntryInput, the narrow
ten-field subset the feed actually renders (deliberately not a row
converter), and acquisitionEntryOpts makes the two legitimate
differences explicit instead of incidental:

- labelLibraries: unscoped search labels entries with their library.
- includePdfAlternate: catalogs offer the PDF alternate for reflowable
  books; search historically does not. Kept as-is — making search match
  catalogs would be a behavior change, flagged in the opts doc.

The four OPDS behaviors tests are green in the test-runner container.
Flagged, not from this change: TestOPDSLibraryFolders fails identically
with the change stashed — pre-existing rot worth its own look (it never
ran in the hardening batteries because the -run patterns didn't match
it).
This commit is contained in:
John O'Keefe committed 2026-10-17 17:23:33 -04:00
1 parent 6b3bdef052
commit 29a546229d
1 file changed
+100 -75
+100 -75
View File
@@ -378,7 +378,7 @@ func (h *OPDSHandler) serveAllBooksCatalog(c *echo.Context) error {
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search") feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
// Add entries // Add entries
h.addAcquisitionEntries(c.Request().Context(), feed, device, deviceID, allItems, baseURL, opdsBaseURL, token) h.addAcquisitionEntries(c.Request().Context(), feed, device, deviceID, listRowsToOpdsInputs(allItems), baseURL, opdsBaseURL, token, acquisitionEntryOpts{includePdfAlternate: true})
// Generate XML // Generate XML
xmlString, err := feed.GenerateXMLString() xmlString, err := feed.GenerateXMLString()
@@ -390,19 +390,50 @@ func (h *OPDSHandler) serveAllBooksCatalog(c *echo.Context) error {
return c.String(http.StatusOK, xmlString) return c.String(http.StatusOK, xmlString)
} }
// addAcquisitionEntries fills an acquisition feed with book entries for the // opdsEntryInput is the narrow field set an OPDS acquisition entry
// given items. Shared body of the all-books and per-library catalogs. // needs, normalized across the different sqlc row shapes (library
func (h *OPDSHandler) addAcquisitionEntries(ctx context.Context, feed *opds.Feed, device database.Devices, deviceID string, items []database.ListMediaItemsByLibraryRow, baseURL, opdsBaseURL, token string) { // 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) userUUID := uuid.UUID(device.UserID.Bytes)
for _, item := range items { for _, item := range items {
bookUUID := uuid.UUID(item.ID.Bytes).String() bookUUID := uuid.UUID(item.id.Bytes).String()
title := item.Title title := item.title
author := "" author := ""
if item.Author.Valid { if item.author.Valid {
author = item.Author.String author = item.author.String
} }
updated := item.UpdatedAt.Time.UTC().Format("2006-01-02T15:04:05Z") updated := item.updatedAt.Time.UTC().Format("2006-01-02T15:04:05Z")
entry := opds.NewEntry( entry := opds.NewEntry(
fmt.Sprintf("urn:uuid:%s", bookUUID), fmt.Sprintf("urn:uuid:%s", bookUUID),
@@ -412,36 +443,47 @@ func (h *OPDSHandler) addAcquisitionEntries(ctx context.Context, feed *opds.Feed
) )
// Add description // Add description
if item.Description.Valid { if item.description.Valid {
entry.SetSummary(item.Description.String) 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 // Add acquisition link using the item's real mime type
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token) downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype)) entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.mimeType, item.formatMimetype))
// Only offer reflowable conversions (kepub/pdf) for ebooks; comic // Only offer reflowable conversions (kepub/pdf) for ebooks; comic
// archives are served as-is in their native format. // archives are served as-is in their native format.
if !isComicArchive(item.FormatGroup) { if !isComicArchive(item.formatGroup) {
if device.DeviceType == "kobo" { if device.DeviceType == "kobo" {
kepubURL := downloadURL + "&format=kepub" kepubURL := downloadURL + "&format=kepub"
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip") entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
} }
if opts.includePdfAlternate {
pdfURL := downloadURL + "&format=pdf" pdfURL := downloadURL + "&format=pdf"
entry.AddAlternateLink(pdfURL, "application/pdf") entry.AddAlternateLink(pdfURL, "application/pdf")
} }
}
// Add canonical identifier // Add canonical identifier
entry.SetIdentifier(bookUUID) entry.SetIdentifier(bookUUID)
// Add SHA-256 metadata // Add SHA-256 metadata
if item.FileSha256.Valid { if item.fileSha256.Valid {
entry.AddMetadata("bookhoard:sha256", item.FileSha256.String) entry.AddMetadata("bookhoard:sha256", item.fileSha256.String)
} }
// Add collections as categories // Add collections as categories
collections, err := h.db.GetCollectionsForBook(ctx, pgtype.UUID{Bytes: item.ID.Bytes, Valid: true}) collections, err := h.db.GetCollectionsForBook(ctx, item.mediaItemID)
if err == nil { if err == nil {
collectionScheme := fmt.Sprintf("%s/collections", baseURL) collectionScheme := fmt.Sprintf("%s/collections", baseURL)
for _, col := range collections { for _, col := range collections {
@@ -455,6 +497,29 @@ func (h *OPDSHandler) addAcquisitionEntries(ctx context.Context, feed *opds.Feed
} }
} }
// 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 // GetLibraryCatalog serves an acquisition feed scoped to one of the device
// owner's visible libraries. Its search link is library-scoped: OPDS clients // owner's visible libraries. Its search link is library-scoped: OPDS clients
// only substitute {searchTerms}, so opening search from inside this folder // only substitute {searchTerms}, so opening search from inside this folder
@@ -571,7 +636,7 @@ func (h *OPDSHandler) GetLibraryCatalog(c *echo.Context) error {
feed.AddLink(rootURL, navFeedMediaType, "up") feed.AddLink(rootURL, navFeedMediaType, "up")
// Add entries // Add entries
h.addAcquisitionEntries(c.Request().Context(), feed, device, deviceID, items, baseURL, opdsBaseURL, token) h.addAcquisitionEntries(c.Request().Context(), feed, device, deviceID, listRowsToOpdsInputs(items), baseURL, opdsBaseURL, token, acquisitionEntryOpts{includePdfAlternate: true})
// Generate XML // Generate XML
xmlString, err := feed.GenerateXMLString() xmlString, err := feed.GenerateXMLString()
@@ -696,66 +761,26 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
// OpenSearch paging metadata (search results are a single page) // OpenSearch paging metadata (search results are a single page)
feed.SetPagination(len(allItems), len(allItems), 1) feed.SetPagination(len(allItems), len(allItems), 1)
// Add entries (same as catalog) // Add entries (same as catalog). Unscoped searches label each entry
userUUID := uuid.UUID(userID) // 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 { for _, item := range allItems {
bookUUID := uuid.UUID(item.ID.Bytes).String() inputs = append(inputs, opdsEntryInput{
title := item.Title id: item.ID,
author := "" mediaItemID: item.ID,
if item.Author.Valid { title: item.Title,
author = item.Author.String author: item.Author,
} description: item.Description,
updatedAt: item.UpdatedAt,
updated := item.UpdatedAt.Time.UTC().Format("2006-01-02T15:04:05Z") mimeType: item.MimeType,
formatGroup: item.FormatGroup,
entry := opds.NewEntry( libraryName: item.LibraryName,
fmt.Sprintf("urn:uuid:%s", bookUUID), fileSha256: item.FileSha256,
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)
} }
h.addAcquisitionEntries(c.Request().Context(), feed, device, deviceID, inputs, baseURL, opdsBaseURL, token,
acquisitionEntryOpts{labelLibraries: !scopedLibraryID.Valid})
xmlString, err := feed.GenerateXMLString() xmlString, err := feed.GenerateXMLString()
if err != nil { if err != nil {