Skip to content

feat(books): add AudiobookCovers.com as a cover search source#565

Merged
neonsolstice merged 1 commit into
bookorbit:mainfrom
Dukko:BO-564-add-audiobookcovers-provider
Jul 6, 2026
Merged

feat(books): add AudiobookCovers.com as a cover search source#565
neonsolstice merged 1 commit into
bookorbit:mainfrom
Dukko:BO-564-add-audiobookcovers-provider

Conversation

@Dukko

@Dukko Dukko commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Before you submit

This issue (#564) was opened by me and doesn't yet have explicit maintainer approval (no 👍 or "go ahead" comment). Opening this PR anyway per my own judgment call — happy to convert to draft or hold if maintainers would rather discuss first.


What does this PR do?

Adds AudiobookCovers.com as a cover search source in the metadata editor's cover picker, as requested in the issue.

AudiobookCovers.com has no public API — it renders search results server-side and embeds them as a serialized object graph in the page HTML. The new AudiobookCoversCoverProvider fetches /search?q=<title author> and extracts the image records (id + jpeg URLs at 320/640/1280px) via a targeted regex, following the same pattern as the existing DuckDuckGoCoverProvider/ITunesCoverProvider scrapers.

The site only contains community-submitted square audiobook fan art (no title/author metadata — results are ranked by an embedding-similarity distance field, not text matching), so the provider:

  • returns no results unless isAudiobook is set
  • is only shown as a source option in the UI when the audiobook cover toggle is on (auto-resets to DuckDuckGo if you flip the toggle off while it's selected)

No config/settings changes — it's always active for audiobook searches, same as DuckDuckGo (no enable/disable toggle exists for that provider either).

Closes: #564

How did you test this?

  • Verified the scraping approach against the live site: used the browser to drive a real search, inspected network requests (confirmed there's no XHR/JSON API — everything is embedded in the initial SSR HTML), then fetched real /search?q= responses with curl and validated the extraction regex against them in a standalone script (correctly extracted all 31/31 embedded results, in relevance order).
  • Added unit tests for the new provider (audiobook gating, empty title, parsing, dedup by id, HTTP failure, network failure) and updated the module wiring, DTO, and CoverSearchDrawer tests for the new provider key/UI option.
  • Could not run the project's own pnpm test/lint locally — my working copy is on an exFAT-formatted mount, which doesn't support the symlinks pnpm needs, so pnpm install couldn't complete in this environment. I read through the diff carefully and hand-verified the regex against real page data instead, but the actual test suite has not been executed against this code. Flagging this explicitly rather than checking the box below — happy to fix anything CI turns up.

Screenshots

Not included — this is primarily a backend addition plus one new <option> in an existing dropdown; I didn't have a working local dev server to capture the UI in this session (same environment issue as above).

Anything non-obvious in the diff?

  • The provider parses the site's internal serialized SSR payload (TanStack Start's $R[...] object-graph format) rather than a documented API, since none exists. This is inherently a little more fragile than a JSON API integration — if AudiobookCovers.com changes its frontend internals, the regex may need updating. It scrapes only id and the three jpeg size URLs, ignoring the surrounding blurhash/color/distance fields.
  • All images at a given size are square (verified 1280x1280 and 320x320 on sample downloads), so width/height are hardcoded to 1280 rather than derived from any field in the payload (the site doesn't expose dimensions).

Checklist

  • I've read through my own diff
  • This PR was discussed in an issue and has maintainer approval (for new features) — issue is open, not yet approved
  • Lint and tests pass locally — could not run locally, see testing notes above
  • No unintended files included (build artifacts, .env, personal configs)

AI tools used: Claude Code
Extent: Used to research the target site's structure (browser inspection + curl), write the provider implementation, tests, and this PR description. I reviewed the full diff and the site-scraping logic myself before submitting.

Summary by CodeRabbit

  • New Features

    • Added support for an additional audiobook cover search source.
    • The source selector now shows the new option only when audiobook cover mode is enabled.
  • Bug Fixes

    • Switching off audiobook mode now automatically resets the selected source to a supported default.
    • Improved cover search handling for audiobook-specific queries.

AudiobookCovers.com hosts community-submitted audiobook cover art and has
no public API, so results are scraped from its server-rendered search
page. Since it only indexes square audiobook fan covers (no title/author
metadata), the provider is scoped to audiobook searches and surfaced in
the cover picker only when the audiobook toggle is on.

Closes: bookorbit#564

Co-Authored-By: Claude Sonnet 5 <[email protected]>
@github-actions github-actions Bot added server Changes affecting server-side code, APIs, or backend behavior. client Changes affecting the client application or frontend behavior. labels Jul 6, 2026
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a new AudiobookCoversCoverProvider that scrapes audiobookcovers.com for audiobook cover images, registers it in the server's CoverModule and provider key type, and updates the client CoverSearchDrawer to conditionally display and reset the audiobookcovers source option based on audiobook mode.

Changes

AudiobookCovers source support

Layer / File(s) Summary
Provider key and DTO validation
server/src/modules/cover/providers/cover-provider.ts, server/src/modules/cover/dto/search-covers-query.dto.test.ts
Adds AUDIOBOOKCOVERS_PROVIDER_KEY to the provider key tuple/type and validates audiobookcovers as an accepted provider value.
Provider implementation and tests
server/src/modules/cover/providers/audiobookcovers-cover-provider.ts, server/src/modules/cover/providers/audiobookcovers-cover-provider.test.ts
Implements AudiobookCoversCoverProvider, which fetches and scrapes audiobookcovers.com HTML for audiobook searches, deduplicates and limits results, handles fetch failures/errors, and includes full test coverage.
CoverModule registration
server/src/modules/cover/cover.module.ts, server/src/modules/cover/cover.module.test.ts
Registers AudiobookCoversCoverProvider in PROVIDER_CLASSES and updates factory wiring and tests to include the new provider.
CoverSearchDrawer UI and tests
client/src/features/book/components/detail/tabs/CoverSearchDrawer.vue, client/src/features/book/components/detail/tabs/CoverSearchDrawer.test.ts
Extends the provider type union, conditionally shows the AudiobookCovers source option only in audiobook mode, resets the provider selection when audiobook mode is disabled, and adds tests for these behaviors.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CoverModule
  participant AudiobookCoversCoverProvider
  participant AudiobookCoversSite

  CoverModule->>AudiobookCoversCoverProvider: search(params)
  AudiobookCoversCoverProvider->>AudiobookCoversCoverProvider: buildQuery(title, author)
  alt not audiobook or empty query
    AudiobookCoversCoverProvider-->>CoverModule: []
  else valid query
    AudiobookCoversCoverProvider->>AudiobookCoversSite: fetch(searchUrl, headers, timeout)
    alt fetch succeeds (200 OK)
      AudiobookCoversSite-->>AudiobookCoversCoverProvider: HTML response
      AudiobookCoversCoverProvider->>AudiobookCoversCoverProvider: parseResults(html), dedupe by id
      AudiobookCoversCoverProvider-->>CoverModule: CoverSearchResult[] (max 25)
    else fetch fails or non-OK status
      AudiobookCoversCoverProvider->>AudiobookCoversCoverProvider: log warning
      AudiobookCoversCoverProvider-->>CoverModule: []
    end
  end
Loading

Suggested labels: server, client

Suggested reviewers: neonsolstice

Poem

A rabbit hopped to find a cover,
Scraped the web like no other,
Audiobooks now show their face,
DuckDuckGo held its place,
Toggle off, and back it goes —
🐰 another feature grows!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the main feature: adding AudiobookCovers.com as a cover search source.
Description check ✅ Passed The description covers purpose, implementation, testing notes, screenshots, and non-obvious details, matching the template well.
Linked Issues check ✅ Passed The PR fulfills #564 by adding AudiobookCovers as an audiobook cover search source and exposing it in the cover picker.
Out of Scope Changes check ✅ Passed The changes stay focused on the new AudiobookCovers source, its wiring, validation, and related tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
client/src/features/book/components/detail/tabs/CoverSearchDrawer.vue (1)

29-46: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

searchProvider not reset when drawer reopens with a different audiobook state.

toggleAudiobookSearch (Lines 44-46) resets searchProvider away from 'audiobookcovers' when the checkbox is toggled off, but the watch(() => props.open, ...) handler (Lines 29-40) — which re-syncs isAudiobookSearch from props.isAudiobook whenever the drawer reopens for a new book — does not perform the same reset.

If a user selects "AudiobookCovers" for one book, closes the drawer, then reopens it for a non-audiobook, isAudiobookSearch becomes false (hiding the option) while searchProvider silently remains 'audiobookcovers'. The select loses a valid displayed selection, and searching would send provider=audiobookcovers with isAudiobook=false, which the server-side provider always rejects — producing an unexplained empty result set.

🐛 Proposed fix
 watch(
   () => props.open,
   (val) => {
     if (val) {
       searchTitle.value = props.initialTitle
       searchAuthor.value = props.initialAuthor
       isAudiobookSearch.value = props.isAudiobook
       searchResults.value = []
       hasSearched.value = false
+      if (!isAudiobookSearch.value && searchProvider.value === 'audiobookcovers') {
+        searchProvider.value = 'duckduckgo'
+      }
     }
   },
 )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/features/book/components/detail/tabs/CoverSearchDrawer.vue` around
lines 29 - 46, `searchProvider` is not being reset when `CoverSearchDrawer`
reopens with a different audiobook state. Update the `watch(() => props.open,
...)` handler to keep `searchProvider` consistent with `props.isAudiobook`, the
same way `toggleAudiobookSearch()` does when turning audiobook search off. When
`props.isAudiobook` is false and the current provider is `'audiobookcovers'`,
reset it to a valid non-audiobook provider such as `'duckduckgo'` so reopening
the drawer cannot leave an invalid hidden selection.
🧹 Nitpick comments (1)
server/src/modules/cover/providers/audiobookcovers-cover-provider.ts (1)

10-10: 🧹 Nitpick | 🔵 Trivial

Scraping approach is inherently fragile to markup changes.

The regex bridges from id:"..." to the nearest serialized jpeg:$R[n]={...} block, coupling parsing tightly to audiobookcovers.com's current server-rendered output. If the site changes its markup/serialization, parseResults will silently return an empty array with no distinguishing signal from a legitimate "no results" case, making regressions hard to detect.

Consider logging when a response is fetched successfully (200 OK) but zero results are parsed, to help distinguish "no results" from "scraper broke."

[reliability]

Also applies to: 52-71

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/modules/cover/providers/audiobookcovers-cover-provider.ts` at line
10, The parsing in parseResults is fragile and can fail silently when
audiobookcovers.com changes its serialized markup. Update the result handling
around RESULT_PATTERN and parseResults so that a successful 200 response with
zero parsed matches emits a clear log/warning, distinguishing an actual
no-results case from a scraper break. Use the existing provider flow in
audiobookcovers-cover-provider to detect the fetched response status and log
when parsing returns an empty array.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@client/src/features/book/components/detail/tabs/CoverSearchDrawer.test.ts`:
- Around line 133-156: Add a test covering the open-prop reset path in
CoverSearchDrawer.test.ts: the current cases only verify the checkbox-driven
reset, so extend the existing CoverSearchDrawer/mountDrawer setup to simulate
closing and reopening the drawer via props.open while switching isAudiobook from
true to false. After selecting audiobookcovers, assert that the watch on
props.open causes searchProvider to reset so the select is no longer
audiobookcovers (for example, it falls back to duckduckgo).

---

Outside diff comments:
In `@client/src/features/book/components/detail/tabs/CoverSearchDrawer.vue`:
- Around line 29-46: `searchProvider` is not being reset when
`CoverSearchDrawer` reopens with a different audiobook state. Update the
`watch(() => props.open, ...)` handler to keep `searchProvider` consistent with
`props.isAudiobook`, the same way `toggleAudiobookSearch()` does when turning
audiobook search off. When `props.isAudiobook` is false and the current provider
is `'audiobookcovers'`, reset it to a valid non-audiobook provider such as
`'duckduckgo'` so reopening the drawer cannot leave an invalid hidden selection.

---

Nitpick comments:
In `@server/src/modules/cover/providers/audiobookcovers-cover-provider.ts`:
- Line 10: The parsing in parseResults is fragile and can fail silently when
audiobookcovers.com changes its serialized markup. Update the result handling
around RESULT_PATTERN and parseResults so that a successful 200 response with
zero parsed matches emits a clear log/warning, distinguishing an actual
no-results case from a scraper break. Use the existing provider flow in
audiobookcovers-cover-provider to detect the fetched response status and log
when parsing returns an empty array.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f67ce432-73fa-47c9-9f75-00b352ea2ee4

📥 Commits

Reviewing files that changed from the base of the PR and between b0c1dd2 and 0b31e52.

📒 Files selected for processing (8)
  • client/src/features/book/components/detail/tabs/CoverSearchDrawer.test.ts
  • client/src/features/book/components/detail/tabs/CoverSearchDrawer.vue
  • server/src/modules/cover/cover.module.test.ts
  • server/src/modules/cover/cover.module.ts
  • server/src/modules/cover/dto/search-covers-query.dto.test.ts
  • server/src/modules/cover/providers/audiobookcovers-cover-provider.test.ts
  • server/src/modules/cover/providers/audiobookcovers-cover-provider.ts
  • server/src/modules/cover/providers/cover-provider.ts

Comment on lines +133 to +156
describe('audiobookcovers source option', () => {
it('is hidden from the source dropdown for regular books', () => {
const wrapper = mountDrawer({ isAudiobook: false })
const options = wrapper.findAll('option').map((option) => option.element.value)
expect(options).not.toContain('audiobookcovers')
})

it('is shown in the source dropdown for audiobooks', () => {
const wrapper = mountDrawer({ isAudiobook: true })
const options = wrapper.findAll('option').map((option) => option.element.value)
expect(options).toContain('audiobookcovers')
})

it('resets the selected provider away from audiobookcovers when audiobook is unchecked', async () => {
const wrapper = mountDrawer({ isAudiobook: true })
await wrapper.find('select').setValue('audiobookcovers')

await wrapper.find('input[type="checkbox"]').trigger('change')

const select = wrapper.find('select').element as HTMLSelectElement
expect(select.value).toBe('duckduckgo')
})
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add a test for the open-prop-triggered reset path.

Current tests only exercise the checkbox-triggered reset (Lines 146-154). Given the state-desync bug flagged in CoverSearchDrawer.vue (watch on props.open doesn't reset searchProvider), consider adding a test that mounts with isAudiobook: true, selects audiobookcovers, sets open to false then back to true with isAudiobook: false, and asserts the select value is no longer audiobookcovers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/features/book/components/detail/tabs/CoverSearchDrawer.test.ts`
around lines 133 - 156, Add a test covering the open-prop reset path in
CoverSearchDrawer.test.ts: the current cases only verify the checkbox-driven
reset, so extend the existing CoverSearchDrawer/mountDrawer setup to simulate
closing and reopening the drawer via props.open while switching isAudiobook from
true to false. After selecting audiobookcovers, assert that the watch on
props.open causes searchProvider to reset so the select is no longer
audiobookcovers (for example, it falls back to duckduckgo).

@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.87179% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
.../cover/providers/audiobookcovers-cover-provider.ts 93.93% 0 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@neonsolstice
neonsolstice merged commit 7184294 into bookorbit:main Jul 6, 2026
25 checks passed
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.2.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

@github-actions github-actions Bot added the released Issue or PR is included in a released version. label Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

client Changes affecting the client application or frontend behavior. released Issue or PR is included in a released version. server Changes affecting server-side code, APIs, or backend behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Add AudiobookCovers as a source

2 participants