mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user