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

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: #564

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Dukko
2026-07-05 23:21:14 -05:00
committed by GitHub
parent 0cc6a5f5c2
commit fe57701f51
8 changed files with 216 additions and 6 deletions
@@ -130,6 +130,30 @@ describe('CoverSearchDrawer', () => {
})
})
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')
})
})
describe('aspect ratio', () => {
it('uses portrait aspect class for regular books', () => {
const wrapper = mountDrawer({ isAudiobook: false })
@@ -18,7 +18,7 @@ const emit = defineEmits<{
const searchTitle = ref(props.initialTitle)
const searchAuthor = ref(props.initialAuthor)
const searchProvider = ref<'duckduckgo' | 'itunes' | 'all'>('duckduckgo')
const searchProvider = ref<'duckduckgo' | 'itunes' | 'audiobookcovers' | 'all'>('duckduckgo')
const isAudiobookSearch = ref(props.isAudiobook)
const isSearching = ref(false)
const searchResults = ref<CoverSearchResult[]>([])
@@ -41,6 +41,9 @@ watch(
function toggleAudiobookSearch() {
isAudiobookSearch.value = !isAudiobookSearch.value
if (!isAudiobookSearch.value && searchProvider.value === 'audiobookcovers') {
searchProvider.value = 'duckduckgo'
}
}
async function performSearch() {
@@ -120,6 +123,7 @@ function handleOpenChange(val: boolean) {
>
<option value="duckduckgo">DuckDuckGo</option>
<option value="itunes">iTunes</option>
<option v-if="isAudiobookSearch" value="audiobookcovers">AudiobookCovers</option>
<option value="all">All Sources</option>
</select>
</div>
@@ -5,6 +5,7 @@ import { CoverController } from './cover.controller';
import { CoverModule } from './cover.module';
import { CoverService } from './cover.service';
import { CoverProviderRegistry } from './provider-registry';
import { AudiobookCoversCoverProvider } from './providers/audiobookcovers-cover-provider';
import { DuckDuckGoCoverProvider } from './providers/duckduckgo-cover-provider';
import { ITunesCoverProvider } from './providers/itunes-cover-provider';
@@ -15,7 +16,9 @@ describe('CoverModule', () => {
const exportsList = Reflect.getMetadata('exports', CoverModule) as unknown[];
expect(controllers).toEqual([CoverController]);
expect(providers).toEqual(expect.arrayContaining([DuckDuckGoCoverProvider, ITunesCoverProvider, CoverProviderRegistry, CoverService]));
expect(providers).toEqual(
expect.arrayContaining([DuckDuckGoCoverProvider, ITunesCoverProvider, AudiobookCoversCoverProvider, CoverProviderRegistry, CoverService]),
);
expect(exportsList).toEqual([CoverService]);
const providerFactory = providers.find((value): value is { provide: symbol; inject: unknown[]; useFactory: (...args: unknown[]) => unknown[] } =>
@@ -23,10 +26,11 @@ describe('CoverModule', () => {
);
expect(providerFactory).toBeDefined();
expect(providerFactory?.inject).toEqual([DuckDuckGoCoverProvider, ITunesCoverProvider]);
expect(providerFactory?.inject).toEqual([DuckDuckGoCoverProvider, ITunesCoverProvider, AudiobookCoversCoverProvider]);
const duck = { key: 'duckduckgo' };
const itunes = { key: 'itunes' };
expect(providerFactory?.useFactory(duck, itunes)).toEqual([duck, itunes]);
const audiobookcovers = { key: 'audiobookcovers' };
expect(providerFactory?.useFactory(duck, itunes, audiobookcovers)).toEqual([duck, itunes, audiobookcovers]);
});
});
+2 -1
View File
@@ -8,11 +8,12 @@ import { COVER_PROVIDERS } from './constants';
import { CoverController } from './cover.controller';
import { CoverService } from './cover.service';
import type { CoverProvider } from './providers/cover-provider';
import { AudiobookCoversCoverProvider } from './providers/audiobookcovers-cover-provider';
import { DuckDuckGoCoverProvider } from './providers/duckduckgo-cover-provider';
import { ITunesCoverProvider } from './providers/itunes-cover-provider';
import { CoverProviderRegistry } from './provider-registry';
const PROVIDER_CLASSES = [DuckDuckGoCoverProvider, ITunesCoverProvider];
const PROVIDER_CLASSES = [DuckDuckGoCoverProvider, ITunesCoverProvider, AudiobookCoversCoverProvider];
@Module({
imports: [BookModule, BookMetadataLockModule, FileWriteModule, LibraryModule, MetadataPreferencesModule],
@@ -38,6 +38,7 @@ describe('SearchCoversQueryDto', () => {
it('allows known provider values and rejects unknown values', async () => {
expect((await validateInput({ title: 'Dune', provider: 'duckduckgo' })).errors).toHaveLength(0);
expect((await validateInput({ title: 'Dune', provider: 'itunes' })).errors).toHaveLength(0);
expect((await validateInput({ title: 'Dune', provider: 'audiobookcovers' })).errors).toHaveLength(0);
expect((await validateInput({ title: 'Dune', provider: 'all' })).errors).toHaveLength(0);
expect((await validateInput({ title: 'Dune', provider: 'unknown' })).errors).toEqual(
expect.arrayContaining([expect.objectContaining({ property: 'provider' })]),
@@ -0,0 +1,97 @@
import { Logger } from '@nestjs/common';
import { AudiobookCoversCoverProvider } from './audiobookcovers-cover-provider';
const BLURHASH = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAAA'.repeat(3);
function resultBlock(id: string, size1280: string) {
return `id:"${id}",blurhashUrl:"${BLURHASH}",source:"https://redd.it/abc",url:"https://images.audiobookcovers.com/original/${id}.png",jpeg:$R[9]={320:"https://images.audiobookcovers.com/jpeg/320/${id}.jpg",640:"https://images.audiobookcovers.com/jpeg/640/${id}.jpg",1280:"${size1280}"},webp:$R[10]={},primaryColor:$R[11]={},searchable:!0,distance:0.71,from_old_database:!0},`;
}
function pageHtml(ids: string[]) {
const blocks = ids.map((id) => resultBlock(id, `https://images.audiobookcovers.com/jpeg/1280/${id}.jpg`)).join('');
return `<script>$R[8]={images:$R[9]=[${blocks}]}</script>`;
}
describe('AudiobookCoversCoverProvider', () => {
const originalFetch = global.fetch;
const loggerWarn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
let fetchMock: ReturnType<typeof vi.fn>;
let provider: AudiobookCoversCoverProvider;
beforeEach(() => {
vi.clearAllMocks();
provider = new AudiobookCoversCoverProvider();
fetchMock = vi.fn();
global.fetch = fetchMock as never;
});
afterAll(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it('returns no results when the search is not for an audiobook', async () => {
await expect(provider.search({ title: 'Dune' })).resolves.toEqual([]);
expect(fetchMock).not.toHaveBeenCalled();
});
it('returns no results when title is empty', async () => {
await expect(provider.search({ title: ' ', isAudiobook: true })).resolves.toEqual([]);
expect(fetchMock).not.toHaveBeenCalled();
});
it('parses embedded search results into cover results', async () => {
const html = pageHtml(['1c98a3d5-9c05-4157-81a5-558cbb5ea87a', 'b567a347-947e-4577-a48c-22236657acec']);
fetchMock.mockResolvedValueOnce(new Response(html, { status: 200 }));
const results = await provider.search({ title: 'Dune', author: 'Frank Herbert', isAudiobook: true });
expect(results).toEqual([
{
url: 'https://images.audiobookcovers.com/jpeg/1280/1c98a3d5-9c05-4157-81a5-558cbb5ea87a.jpg',
sourceUrl: 'https://images.audiobookcovers.com/jpeg/1280/1c98a3d5-9c05-4157-81a5-558cbb5ea87a.jpg',
previewUrl: 'https://images.audiobookcovers.com/jpeg/320/1c98a3d5-9c05-4157-81a5-558cbb5ea87a.jpg',
width: 1280,
height: 1280,
source: 'AudiobookCovers',
},
{
url: 'https://images.audiobookcovers.com/jpeg/1280/b567a347-947e-4577-a48c-22236657acec.jpg',
sourceUrl: 'https://images.audiobookcovers.com/jpeg/1280/b567a347-947e-4577-a48c-22236657acec.jpg',
previewUrl: 'https://images.audiobookcovers.com/jpeg/320/b567a347-947e-4577-a48c-22236657acec.jpg',
width: 1280,
height: 1280,
source: 'AudiobookCovers',
},
]);
const [requestUrl] = fetchMock.mock.calls[0] as [string];
const parsed = new URL(requestUrl);
expect(parsed.pathname).toBe('/search');
expect(parsed.searchParams.get('q')).toBe('Dune Frank Herbert');
});
it('deduplicates repeated ids in the response', async () => {
const id = '1c98a3d5-9c05-4157-81a5-558cbb5ea87a';
const html = pageHtml([id, id]);
fetchMock.mockResolvedValueOnce(new Response(html, { status: 200 }));
const results = await provider.search({ title: 'Dune', isAudiobook: true });
expect(results).toHaveLength(1);
});
it('returns no results when the request fails', async () => {
fetchMock.mockResolvedValueOnce(new Response('', { status: 500 }));
await expect(provider.search({ title: 'Dune', isAudiobook: true })).resolves.toEqual([]);
expect(loggerWarn).toHaveBeenCalledWith(expect.stringContaining('Search failed with status 500'));
});
it('returns no results when fetch throws', async () => {
fetchMock.mockRejectedValueOnce(new Error('network down'));
await expect(provider.search({ title: 'Dune', isAudiobook: true })).resolves.toEqual([]);
expect(loggerWarn).toHaveBeenCalledWith(expect.stringContaining('network down'));
});
});
@@ -0,0 +1,78 @@
import { Injectable, Logger } from '@nestjs/common';
import { CoverSearchResult } from '@bookorbit/types';
import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
import { COVER_PROXY_USER_AGENT } from '../constants';
import { AUDIOBOOKCOVERS_PROVIDER_KEY, CoverProvider, CoverSearchParams } from './cover-provider';
// AudiobookCovers.com renders search results server-side and embeds them as a serialized
// object graph in the page (no public JSON API), so we scrape the id + jpeg URLs out of the HTML.
const RESULT_PATTERN = /id:"([0-9a-fA-F-]{36})".*?jpeg:\$R\[\d+\]=\{320:"([^"]+)",640:"([^"]+)",1280:"([^"]+)"\}/gs;
const COVER_SIZE = 1280;
const MAX_RESULTS = 25;
@Injectable()
export class AudiobookCoversCoverProvider implements CoverProvider {
readonly key = AUDIOBOOKCOVERS_PROVIDER_KEY;
private readonly logger = new Logger(AudiobookCoversCoverProvider.name);
private static readonly SEARCH_URL = 'https://www.audiobookcovers.com/search';
async search(params: CoverSearchParams): Promise<CoverSearchResult[]> {
if (!params.isAudiobook) return [];
const query = this.buildQuery(params);
if (!query) return [];
const url = new URL(AudiobookCoversCoverProvider.SEARCH_URL);
url.searchParams.set('q', query);
try {
const response = await fetch(url.toString(), {
headers: {
'User-Agent': COVER_PROXY_USER_AGENT,
Accept: 'text/html,application/xhtml+xml',
},
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
this.logger.warn(`Search failed with status ${response.status} for query "${sanitizeLogValue(query)}"`);
return [];
}
const html = await response.text();
return this.parseResults(html).slice(0, MAX_RESULTS);
} catch (error) {
this.logger.warn(`Search failed for query "${sanitizeLogValue(query)}": ${error instanceof Error ? error.message : String(error)}`);
return [];
}
}
private parseResults(html: string): CoverSearchResult[] {
const results: CoverSearchResult[] = [];
const seenIds = new Set<string>();
for (const match of html.matchAll(RESULT_PATTERN)) {
const [, id, previewUrl, , fullUrl] = match;
if (seenIds.has(id)) continue;
seenIds.add(id);
results.push({
url: fullUrl,
sourceUrl: fullUrl,
previewUrl,
width: COVER_SIZE,
height: COVER_SIZE,
source: 'AudiobookCovers',
});
}
return results;
}
private buildQuery(params: CoverSearchParams): string {
const title = params.title?.trim() ?? '';
const author = params.author?.trim() ?? '';
return author ? `${title} ${author}`.trim() : title;
}
}
@@ -2,7 +2,8 @@ import { CoverSearchResult } from '@bookorbit/types';
export const DUCKDUCKGO_PROVIDER_KEY = 'duckduckgo' as const;
export const ITUNES_PROVIDER_KEY = 'itunes' as const;
export const COVER_PROVIDER_KEYS = [DUCKDUCKGO_PROVIDER_KEY, ITUNES_PROVIDER_KEY] as const;
export const AUDIOBOOKCOVERS_PROVIDER_KEY = 'audiobookcovers' as const;
export const COVER_PROVIDER_KEYS = [DUCKDUCKGO_PROVIDER_KEY, ITUNES_PROVIDER_KEY, AUDIOBOOKCOVERS_PROVIDER_KEY] as const;
export const COVER_PROVIDER_ALL_KEY = 'all' as const;
export const DEFAULT_COVER_PROVIDER_KEY = DUCKDUCKGO_PROVIDER_KEY;