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:
1 parent
6b3bdef052
commit
29a546229d
1 file changed
+100
-75
+100
-75
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user