diff --git a/internal/handlers/opds.go b/internal/handlers/opds.go index af9308d2..23d9daa6 100644 --- a/internal/handlers/opds.go +++ b/internal/handlers/opds.go @@ -378,7 +378,7 @@ func (h *OPDSHandler) serveAllBooksCatalog(c *echo.Context) error { feed.AddLink(searchURL, "application/opensearchdescription+xml", "search") // 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 xmlString, err := feed.GenerateXMLString() @@ -390,19 +390,50 @@ func (h *OPDSHandler) serveAllBooksCatalog(c *echo.Context) error { 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) { +// 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 + bookUUID := uuid.UUID(item.id.Bytes).String() + title := item.title author := "" - if item.Author.Valid { - author = item.Author.String + if item.author.Valid { + 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( fmt.Sprintf("urn:uuid:%s", bookUUID), @@ -412,36 +443,47 @@ func (h *OPDSHandler) addAcquisitionEntries(ctx context.Context, feed *opds.Feed ) // Add description - if item.Description.Valid { - entry.SetSummary(item.Description.String) + 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)) + 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 !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") + 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) + 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}) + collections, err := h.db.GetCollectionsForBook(ctx, item.mediaItemID) if err == nil { collectionScheme := fmt.Sprintf("%s/collections", baseURL) 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 // owner's visible libraries. Its search link is library-scoped: OPDS clients // 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") // 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 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) feed.SetPagination(len(allItems), len(allItems), 1) - // Add entries (same as catalog) - userUUID := uuid.UUID(userID) + // 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 { - 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) + 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 {