Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/2755-hardcover-audiobook-duration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
### Fixed
- **Hardcover audiobook duration** (#2755, #2756): use a known audio edition runtime before searching without prioritizing it over stronger audio metadata or promoting print formats; preserve locked languages and let metadata refresh replace whitespace-only edition text fields.
7 changes: 7 additions & 0 deletions docs/User-Guide-Wiki.md
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,13 @@ Things worth knowing:
- **Google Books** (free API key) and **Audnexus/Audible** (audiobook
narrator, duration, by ASIN) enrich further.

For a Hardcover audiobook, the chosen audio edition can fill a missing book
duration before indexer search. Audnex may update that duration when an ASIN is
available. Explicit audio formats take priority over an unknown format with a
runtime; runtime breaks ties between equally ranked editions. A runtime alone
does not turn a known print format into an audiobook. Edition hydration respects
a manually locked language, including a language deliberately cleared to empty.

Which of those a given book actually came from is on the book page, under
**Metadata source**. It names the provider, shows the identifier the book is
bound to with a copy button, and lists any other provider ids the same book is
Expand Down
99 changes: 81 additions & 18 deletions internal/bookhydrate/hardcover.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type EditionUpserter interface {
// BookUpdater persists book-level fields promoted during hydration.
type BookUpdater interface {
Update(context.Context, *models.Book) error
FillMissingAudiobookDuration(context.Context, *models.Book) (bool, int, error)
}

// AudiobookEnricher fills audiobook metadata once an ASIN is known.
Expand Down Expand Up @@ -79,6 +80,7 @@ func HydrateHardcoverEditions(ctx context.Context, opts Options) Result {
if book == nil || book.ID == 0 {
return result
}
before := *book
editionForeignID := strings.TrimSpace(opts.ProviderForeignID)
if editionForeignID == "" {
editionForeignID = book.ForeignID
Expand Down Expand Up @@ -118,18 +120,32 @@ func HydrateHardcoverEditions(ctx context.Context, opts Options) Result {
continue
}
result.Upserted++
// UpsertMetadata reloads persisted fields; runtime exists only in the provider response.
edition.DurationSeconds = editions[i].DurationSeconds
if !isLikelyAudioEdition(editions[i]) {
// A retained unknown format must not hide a fetched print format and
// turn that print edition into audio solely through its runtime.
edition.DurationSeconds = 0
}
if isLikelyAudioEdition(edition) {
if editions[i].ASIN != nil && edition.ASIN != nil && strings.TrimSpace(*editions[i].ASIN) != "" &&
!strings.EqualFold(strings.TrimSpace(*editions[i].ASIN), strings.TrimSpace(*edition.ASIN)) {
// The stored ASIN belongs to a different narration than the fetched runtime.
edition.DurationSeconds = 0
}
acceptedAudioEditions = append(acceptedAudioEditions, edition)
}
}

// #806: derive audiobook metadata from the chosen Hardcover edition BEFORE
// running Audnex, so Hardcover's deterministic edition data fills the book
// and Audnex is left to cover only the gaps (narrator, refined duration,
// summary, cover-if-missing). preferredAudioEdition picks the same edition
// maybePromoteASIN promotes the ASIN from, keeping the two derivations
// consistent.
if edition, ok := preferredAudioEdition(acceptedAudioEditions); ok {
// running Audnex, so its edition data fills missing book fields. Audnex can
// then refine duration and fill narrator, summary, or a missing cover.
// Prefer the edition matching the book's ASIN or the one promoted below.
if edition, ok := preferredAudioEdition(acceptedAudioEditions, book.ASIN); ok {
if strings.TrimSpace(book.ASIN) != "" && (edition.ASIN == nil || !strings.EqualFold(strings.TrimSpace(*edition.ASIN), strings.TrimSpace(book.ASIN))) {
// A different known ASIN may be a different narration; leave runtime unknown.
edition.DurationSeconds = 0
}
if deriveAudiobookMetadataFromEdition(book, edition, opts.MediaTypePinned) {
result.MetadataDerived = true
}
Expand All @@ -155,31 +171,72 @@ func HydrateHardcoverEditions(ctx context.Context, opts Options) Result {
// book. Derivation alone (e.g. ASIN already set) is enough to warrant a
// write so the language/cover/duration we pulled isn't lost.
if (result.ASINPromoted || result.MetadataDerived) && opts.Books != nil {
if err := opts.Books.Update(ctx, book); err != nil {
var err error
updated := false
if !result.ASINPromoted && before.DurationSeconds <= 0 && book.DurationSeconds > 0 &&
before.ASIN == book.ASIN && before.MediaType == book.MediaType && before.Status == book.Status &&
before.Language == book.Language && before.ImageURL == book.ImageURL {
var currentDuration int
updated, currentDuration, err = opts.Books.FillMissingAudiobookDuration(ctx, book)
if !updated {
book.DurationSeconds = currentDuration
result.MetadataDerived = false
if err == nil {
slog.Debug("hardcover duration write skipped after concurrent book update", "bookID", book.ID)
}
}
} else {
err = opts.Books.Update(ctx, book)
updated = err == nil
}
if err != nil {
if result.Err == nil {
result.Err = err
}
slog.Warn("hardcover book hydration persist failed", "bookID", book.ID, "asin", book.ASIN, "error", err)
} else {
result.BookUpdated = true
result.BookUpdated = updated
}
}

return result
}

// preferredAudioEdition returns the audio-looking edition the hydrator should
// derive book metadata from — the same edition maybePromoteASIN promotes the
// ASIN from (highest audioEditionScore, first on ties). Returns ok=false when
// there is no audio edition to derive from.
func preferredAudioEdition(editions []models.Edition) (models.Edition, bool) {
// preferredAudioEdition returns the audio-looking edition whose ASIN matches
// the book, or the edition maybePromoteASIN will use when the book has no ASIN.
// Among matching ASINs, prefer the highest audioEditionScore, then an edition
// with a known runtime (first on ties).
func preferredAudioEdition(editions []models.Edition, bookASIN string) (models.Edition, bool) {
best := -1
bestScore := -1
targetASIN := strings.TrimSpace(bookASIN)
if targetASIN == "" {
targetASIN = preferredEditionASIN(editions)
}
for i := range editions {
score := audioEditionScore(editions[i])
if best == -1 || score > bestScore {
matchesASIN := targetASIN != "" && editions[i].ASIN != nil && strings.EqualFold(strings.TrimSpace(*editions[i].ASIN), targetASIN)
if best == -1 {
best = i
bestScore = score
continue
}
bestMatchesASIN := targetASIN != "" && editions[best].ASIN != nil && strings.EqualFold(strings.TrimSpace(*editions[best].ASIN), targetASIN)
if matchesASIN != bestMatchesASIN {
if matchesASIN {
best = i
}
continue
}
score, bestScore := audioEditionScore(editions[i]), audioEditionScore(editions[best])
if score != bestScore {
if score > bestScore {
best = i
}
continue
}
if (matchesASIN || targetASIN == "") && (editions[i].DurationSeconds > 0) != (editions[best].DurationSeconds > 0) {
if editions[i].DurationSeconds > 0 {
best = i
}
continue
}
}
if best == -1 {
Expand Down Expand Up @@ -249,6 +306,10 @@ func deriveAudiobookMetadataFromEdition(book *models.Book, edition models.Editio
changed = true
}
}
if bookAcceptsAudiobookASIN(book) && book.DurationSeconds <= 0 && edition.DurationSeconds > 0 {
book.DurationSeconds = edition.DurationSeconds
changed = true
}

return changed
}
Expand Down Expand Up @@ -309,7 +370,9 @@ func isLikelyAudioEdition(edition models.Edition) bool {
edition.Format,
edition.EditionInfo,
}, " "))
return editionHasAudioMarker(text)
format := strings.TrimSpace(edition.Format)
unknownFormat := format == "" || strings.EqualFold(format, "unknown")
return editionHasAudioMarker(text) || (unknownFormat && edition.DurationSeconds > 0 && !edition.IsEbook)
}

func editionHasAudioMarker(text string) bool {
Expand Down
Loading
Loading