mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(cover): add online cover search with provider infrastructure
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onUnmounted } from 'vue'
|
||||
import { Image, ImagePlus, Link, Loader2, RotateCcw, Upload, X } from 'lucide-vue-next'
|
||||
import { Image, ImagePlus, Link, Loader2, RotateCcw, Search, Upload, X } from 'lucide-vue-next'
|
||||
import type { BookDetail } from '@projectx/types'
|
||||
import { hideOnError } from '../../../lib/metadata-fetch'
|
||||
import { useCoverEditor } from '../../../composables/useCoverEditor'
|
||||
import { useCoverVersions } from '../../../composables/useCoverVersions'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import CoverSearchDrawer from './CoverSearchDrawer.vue'
|
||||
|
||||
const props = defineProps<{ book: BookDetail }>()
|
||||
const emit = defineEmits<{ coverChanged: ['extracted' | 'custom' | null] }>()
|
||||
@@ -32,10 +33,13 @@ async function reExtractCover() {
|
||||
|
||||
const mode = ref<'file' | 'url'>('file')
|
||||
const urlInput = ref('')
|
||||
const isSearchOpen = ref(false)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout>
|
||||
|
||||
const activeSrc = computed(() => previewSrc.value ?? coverUrl(props.book.id, 'cover'))
|
||||
const hasPending = computed(() => !!pendingFile.value || !!pendingUrl.value)
|
||||
const isAudiobook = computed(() => props.book.files.some((f) => f.format === 'audiobook' || f.role === 'audiobook'))
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
@@ -54,6 +58,11 @@ function switchMode(m: 'file' | 'url') {
|
||||
urlInput.value = ''
|
||||
}
|
||||
|
||||
function handleSearchSelect(url: string) {
|
||||
urlInput.value = url
|
||||
setUrl(url)
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
const ok = await confirm()
|
||||
if (ok) emit('coverChanged', 'custom')
|
||||
@@ -129,7 +138,7 @@ onUnmounted(() => clearTimeout(debounceTimer))
|
||||
</div>
|
||||
|
||||
<!-- URL input -->
|
||||
<div v-else>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<input
|
||||
v-model="urlInput"
|
||||
class="w-full h-9 rounded-lg border border-input bg-background px-3 text-xs outline-none focus:ring-1 focus:ring-ring transition-shadow"
|
||||
@@ -138,6 +147,27 @@ onUnmounted(() => clearTimeout(debounceTimer))
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search Button -->
|
||||
<button
|
||||
class="flex items-center justify-center gap-2 w-full h-9 rounded-lg border border-input bg-background text-xs font-medium hover:bg-muted transition-colors"
|
||||
@click="isSearchOpen = true"
|
||||
>
|
||||
<Search class="size-3.5" />
|
||||
Find cover online
|
||||
</button>
|
||||
|
||||
<!-- Cover Search Drawer -->
|
||||
<Teleport to="body">
|
||||
<CoverSearchDrawer
|
||||
v-if="isSearchOpen"
|
||||
:initial-title="book.title ?? ''"
|
||||
:initial-author="book.authors?.[0]?.name ?? ''"
|
||||
:is-audiobook="isAudiobook"
|
||||
@close="isSearchOpen = false"
|
||||
@select="handleSearchSelect"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<!-- Error -->
|
||||
<p v-if="error" class="text-xs text-destructive">{{ error }}</p>
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { Search, Loader2, X, Image as ImageIcon, ExternalLink, Check } from 'lucide-vue-next'
|
||||
import type { CoverSearchResult } from '@projectx/types'
|
||||
|
||||
const props = defineProps<{
|
||||
initialTitle: string
|
||||
initialAuthor: string
|
||||
isAudiobook: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
select: [string]
|
||||
}>()
|
||||
|
||||
const searchTitle = ref(props.initialTitle)
|
||||
const searchAuthor = ref(props.initialAuthor)
|
||||
const isSearching = ref(false)
|
||||
const searchResults = ref<CoverSearchResult[]>([])
|
||||
const hasSearched = ref(false)
|
||||
const titleInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
async function performSearch() {
|
||||
if (!searchTitle.value.trim()) return
|
||||
isSearching.value = true
|
||||
hasSearched.value = true
|
||||
searchResults.value = []
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
title: searchTitle.value.trim(),
|
||||
author: searchAuthor.value.trim(),
|
||||
isAudiobook: String(props.isAudiobook),
|
||||
})
|
||||
const res = await fetch(`/api/v1/books/cover/search?${params}`)
|
||||
if (!res.ok) throw new Error('Search failed')
|
||||
searchResults.value = await res.json()
|
||||
} catch (err) {
|
||||
console.error('Search error:', err)
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(url: string) {
|
||||
emit('select', url)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => titleInput.value?.focus())
|
||||
if (searchTitle.value) performSearch()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex justify-end bg-black/40 backdrop-blur-sm" @click.self="emit('close')">
|
||||
<div class="w-full max-w-2xl bg-background h-full shadow-2xl flex flex-col animate-in slide-in-from-right duration-300">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="p-2 rounded-lg bg-primary/10 text-primary">
|
||||
<Search class="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold">Online Search</h2>
|
||||
<p class="text-[10px] text-muted-foreground uppercase tracking-wider font-medium">Search for book covers</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="p-2 hover:bg-muted rounded-full transition-colors" @click="emit('close')">
|
||||
<X class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar (Sticky) -->
|
||||
<div class="p-4 bg-muted/30 border-b space-y-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1">
|
||||
<label class="text-[10px] font-bold text-muted-foreground ml-1 uppercase">Title</label>
|
||||
<input
|
||||
ref="titleInput"
|
||||
v-model="searchTitle"
|
||||
class="w-full h-10 rounded-xl border border-input bg-background px-3 text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
|
||||
placeholder="e.g. Angelmaker"
|
||||
@keyup.enter="performSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-[10px] font-bold text-muted-foreground ml-1 uppercase">Author</label>
|
||||
<input
|
||||
v-model="searchAuthor"
|
||||
class="w-full h-10 rounded-xl border border-input bg-background px-3 text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
|
||||
placeholder="e.g. Nick Harkaway"
|
||||
@keyup.enter="performSearch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center justify-center gap-2 w-full h-10 rounded-xl bg-primary text-primary-foreground text-sm font-semibold hover:bg-primary/90 transition-all shadow-sm active:scale-[0.98]"
|
||||
:disabled="isSearching"
|
||||
@click="performSearch"
|
||||
>
|
||||
<Loader2 v-if="isSearching" class="size-4 animate-spin" />
|
||||
<Search v-else class="size-4" />
|
||||
{{ isSearching ? 'Searching...' : 'Find covers' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Results Scroll Area -->
|
||||
<div class="flex-1 overflow-y-auto p-4 custom-scrollbar">
|
||||
<div v-if="isSearching" class="grid grid-cols-3 gap-4">
|
||||
<div v-for="i in 9" :key="i" class="aspect-[2/3] rounded-xl bg-muted animate-pulse" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="searchResults.length > 0" class="grid grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="res in searchResults"
|
||||
:key="String(res.url)"
|
||||
class="group relative aspect-[2/3] rounded-xl overflow-hidden bg-muted border border-border/50 hover:border-primary/50 hover:ring-4 hover:ring-primary/10 transition-all cursor-pointer"
|
||||
@click="handleSelect(String(res.url))"
|
||||
>
|
||||
<img :src="res.previewUrl" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" loading="lazy" />
|
||||
|
||||
<!-- Hover Overlay -->
|
||||
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-2">
|
||||
<div class="p-2 rounded-full bg-primary text-white scale-75 group-hover:scale-100 transition-transform">
|
||||
<Check class="size-5" />
|
||||
</div>
|
||||
<span class="text-xs font-bold text-white uppercase tracking-widest">Select Cover</span>
|
||||
</div>
|
||||
|
||||
<!-- Resolution Badge -->
|
||||
<div class="absolute top-2 right-2 px-1.5 py-0.5 rounded-md text-[10px] font-bold text-white backdrop-blur-md shadow-sm"
|
||||
:class="res.width >= 1000 ? 'bg-green-500/80' : 'bg-black/50'">
|
||||
{{ res.width }}x{{ res.height }}
|
||||
</div>
|
||||
|
||||
<!-- Source Badge -->
|
||||
<div class="absolute bottom-2 left-2 px-1.5 py-0.5 rounded-md text-[10px] font-bold text-white bg-black/50 backdrop-blur-md shadow-sm">
|
||||
{{ res.source }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty States -->
|
||||
<div v-else-if="hasSearched" class="h-full flex flex-col items-center justify-center text-center space-y-3 opacity-60 py-12">
|
||||
<div class="p-4 rounded-full bg-muted">
|
||||
<ImageIcon class="size-10 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold">No results found</h3>
|
||||
<p class="text-xs text-muted-foreground">Try adjusting your search terms</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="h-full flex flex-col items-center justify-center text-center space-y-3 opacity-60 py-12">
|
||||
<div class="p-4 rounded-full bg-muted">
|
||||
<Search class="size-10 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold">Ready to search</h3>
|
||||
<p class="text-xs text-muted-foreground">Enter a title and author above</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer / Hint -->
|
||||
<div class="p-4 border-t bg-muted/10">
|
||||
<p class="text-[10px] text-center text-muted-foreground flex items-center justify-center gap-1">
|
||||
<ImageIcon class="size-3" />
|
||||
Tip: High resolution covers are marked in green
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0,0,0,0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -169,6 +169,7 @@ const activeFilterCount = computed(() => filter.value?.rules?.length ?? 0)
|
||||
|
||||
function clearFilters() {
|
||||
filter.value = undefined
|
||||
forgetSavedFilter()
|
||||
}
|
||||
|
||||
const sentinel = ref<HTMLElement | null>(null)
|
||||
|
||||
@@ -75,3 +75,17 @@ export type BookRecommendation = {
|
||||
book: BookCard;
|
||||
score: number;
|
||||
};
|
||||
|
||||
export type CoverSearchResult = {
|
||||
url: number | string; // ID for proxy or direct URL
|
||||
previewUrl: string;
|
||||
sourceUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type CoverSearchResponse = {
|
||||
results: CoverSearchResult[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { BadRequestException, Body, Controller, Delete, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Req } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { MultipartFile } from '@fastify/multipart';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
@@ -13,6 +27,23 @@ type MultipartRequest = FastifyRequest & { file: () => Promise<MultipartFile | u
|
||||
export class CoverController {
|
||||
constructor(private readonly coverService: CoverService) {}
|
||||
|
||||
@Get('cover/search')
|
||||
@RequirePermission('library_edit_metadata')
|
||||
async searchCovers(@Query('title') title: string, @Query('author') author?: string, @Query('isAudiobook') isAudiobook?: string) {
|
||||
return this.coverService.searchCovers({
|
||||
title,
|
||||
author,
|
||||
isAudiobook: isAudiobook === 'true',
|
||||
});
|
||||
}
|
||||
|
||||
@Get('cover/proxy')
|
||||
@RequirePermission('library_view')
|
||||
async proxyImage(@Query('url') url: string, @Res() res: FastifyReply) {
|
||||
const { buffer, contentType } = await this.coverService.proxyImage(url);
|
||||
res.type(contentType).send(buffer);
|
||||
}
|
||||
|
||||
@Post(':id/cover')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@RequirePermission('library_edit_metadata')
|
||||
|
||||
@@ -3,11 +3,12 @@ import { BookModule } from '../book/book.module';
|
||||
import { LibraryModule } from '../library/library.module';
|
||||
import { CoverController } from './cover.controller';
|
||||
import { CoverService } from './cover.service';
|
||||
import { DuckDuckGoCoverProvider } from './providers/duckduckgo-cover-provider';
|
||||
|
||||
@Module({
|
||||
imports: [BookModule, LibraryModule],
|
||||
controllers: [CoverController],
|
||||
providers: [CoverService],
|
||||
providers: [CoverService, DuckDuckGoCoverProvider],
|
||||
exports: [CoverService],
|
||||
})
|
||||
export class CoverModule {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises';
|
||||
@@ -11,6 +11,9 @@ import { coverDirPath, generateThumbnail, imageExt } from '../metadata/lib/cover
|
||||
import { BookRepository } from '../book/book.repository';
|
||||
import { LibraryService } from '../library/library.service';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { DuckDuckGoCoverProvider } from './providers/duckduckgo-cover-provider';
|
||||
import { CoverSearchParams } from './providers/cover-provider';
|
||||
import { CoverSearchResult } from '@projectx/types';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@@ -18,6 +21,7 @@ const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
@Injectable()
|
||||
export class CoverService {
|
||||
private readonly logger = new Logger(CoverService.name);
|
||||
private readonly booksPath: string;
|
||||
|
||||
constructor(
|
||||
@@ -25,10 +29,39 @@ export class CoverService {
|
||||
private readonly bookRepo: BookRepository,
|
||||
private readonly libraryService: LibraryService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly ddgProvider: DuckDuckGoCoverProvider,
|
||||
) {
|
||||
this.booksPath = this.config.get<string>('storage.booksPath')!;
|
||||
}
|
||||
|
||||
async searchCovers(params: CoverSearchParams): Promise<CoverSearchResult[]> {
|
||||
// Currently only DDG provider, but could be multiple
|
||||
const results = await this.ddgProvider.search(params);
|
||||
return results.map((r) => ({
|
||||
...r,
|
||||
// Encode URL for proxying
|
||||
previewUrl: `/api/v1/books/cover/proxy?url=${encodeURIComponent(r.previewUrl)}`,
|
||||
}));
|
||||
}
|
||||
|
||||
async proxyImage(url: string): Promise<{ buffer: Buffer; contentType: string }> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error(`Proxy failed: ${response.status}`);
|
||||
const contentType = response.headers.get('content-type') || 'image/jpeg';
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
return { buffer, contentType };
|
||||
} catch (error) {
|
||||
this.logger.error(`Proxy error for ${url}: ${error.message}`);
|
||||
throw new BadRequestException('Failed to proxy image');
|
||||
}
|
||||
}
|
||||
|
||||
async uploadCover(bookId: number, buffer: Buffer, mimeType: string, user: RequestUser): Promise<void> {
|
||||
if (!mimeType.startsWith('image/')) throw new BadRequestException('File must be an image');
|
||||
await this.verifyAccess(bookId, user);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { CoverSearchResult } from '@projectx/types';
|
||||
|
||||
export interface CoverSearchParams {
|
||||
title: string;
|
||||
author?: string;
|
||||
isAudiobook?: boolean;
|
||||
}
|
||||
|
||||
export interface CoverProvider {
|
||||
key: string;
|
||||
search(params: CoverSearchParams): Promise<CoverSearchResult[]>;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { CoverSearchResult } from '@projectx/types';
|
||||
import { CoverProvider, CoverSearchParams } from './cover-provider';
|
||||
|
||||
@Injectable()
|
||||
export class DuckDuckGoCoverProvider implements CoverProvider {
|
||||
private readonly logger = new Logger(DuckDuckGoCoverProvider.name);
|
||||
readonly key = 'duckduckgo';
|
||||
|
||||
private static readonly SEARCH_BASE_URL = 'https://duckduckgo.com/?q=';
|
||||
private static readonly JSON_BASE_URL = 'https://duckduckgo.com/i.js?o=json&q=';
|
||||
private static readonly SITE_FILTER = '+(site%3Aamazon.com+OR+site%3Agoodreads.com)';
|
||||
private static readonly SEARCH_PARAMS_TALL = '&iar=images&iaf=size%3ALarge%2Clayout%3ATall';
|
||||
private static readonly SEARCH_PARAMS_SQUARE = '&iar=images&iaf=size%3ALarge%2Clayout%3ASquare';
|
||||
|
||||
private static readonly USER_AGENT =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||
|
||||
async search(params: CoverSearchParams): Promise<CoverSearchResult[]> {
|
||||
const { title, author, isAudiobook } = params;
|
||||
const bookType = isAudiobook ? 'audiobook' : 'book';
|
||||
const searchTerm = author ? `${title} ${author} ${bookType}` : `${title} ${bookType}`;
|
||||
|
||||
const searchParams = isAudiobook ? DuckDuckGoCoverProvider.SEARCH_PARAMS_SQUARE : DuckDuckGoCoverProvider.SEARCH_PARAMS_TALL;
|
||||
|
||||
try {
|
||||
// 1. Specific search (Amazon/Goodreads)
|
||||
const siteFilteredResults = await this.performSearch(`${searchTerm} (site:amazon.com OR site:goodreads.com)`, searchParams);
|
||||
|
||||
// 2. General search
|
||||
const generalResults = await this.performSearch(searchTerm, searchParams);
|
||||
|
||||
// Filter and merge
|
||||
const filteredSiteResults = this.filterResults(siteFilteredResults, isAudiobook).slice(0, 10);
|
||||
const filteredGeneralResults = this.filterResults(generalResults, isAudiobook);
|
||||
|
||||
const seenUrls = new Set(filteredSiteResults.map((r) => r.sourceUrl));
|
||||
const combined = [...filteredSiteResults];
|
||||
|
||||
for (const res of filteredGeneralResults) {
|
||||
if (!seenUrls.has(res.sourceUrl)) {
|
||||
combined.push(res);
|
||||
seenUrls.add(res.sourceUrl);
|
||||
if (combined.length >= 25) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: If few results, try broader search (title only)
|
||||
if (combined.length < 5 && author) {
|
||||
const fallbackResults = await this.performSearch(`${title} ${bookType}`, searchParams);
|
||||
const filteredFallback = this.filterResults(fallbackResults, isAudiobook);
|
||||
for (const res of filteredFallback) {
|
||||
if (!seenUrls.has(res.sourceUrl)) {
|
||||
combined.push(res);
|
||||
seenUrls.add(res.sourceUrl);
|
||||
if (combined.length >= 25) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return combined;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error searching DuckDuckGo: ${error.message}`, error.stack);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async performSearch(query: string, searchParams: string): Promise<CoverSearchResult[]> {
|
||||
const searchUrl = `${DuckDuckGoCoverProvider.SEARCH_BASE_URL}${encodeURIComponent(query)}${searchParams}`;
|
||||
|
||||
const response = await fetch(searchUrl, {
|
||||
headers: {
|
||||
'User-Agent': DuckDuckGoCoverProvider.USER_AGENT,
|
||||
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(`Search page fetch failed: ${response.status}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const vqdMatch = html.match(/vqd="(\d+-\d+)"/);
|
||||
if (!vqdMatch) {
|
||||
this.logger.warn('Could not find vqd token');
|
||||
return [];
|
||||
}
|
||||
const vqd = vqdMatch[1];
|
||||
|
||||
const apiUrl = `${DuckDuckGoCoverProvider.JSON_BASE_URL}${encodeURIComponent(query)}${searchParams}&vqd=${vqd}`;
|
||||
const apiResponse = await fetch(apiUrl, {
|
||||
headers: {
|
||||
'User-Agent': DuckDuckGoCoverProvider.USER_AGENT,
|
||||
Accept: 'application/json, text/javascript, */*; q=0.01',
|
||||
Referer: searchUrl,
|
||||
'x-vqd-4': vqd,
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
this.logger.warn(`API fetch failed: ${apiResponse.status}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = (await apiResponse.json()) as any;
|
||||
const results: any[] = data.results || [];
|
||||
|
||||
return results.map((r) => ({
|
||||
previewUrl: r.thumbnail,
|
||||
sourceUrl: r.image,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
source: this.determineSource(r.image),
|
||||
url: r.image, // Will be proxied in the controller
|
||||
}));
|
||||
}
|
||||
|
||||
private filterResults(results: CoverSearchResult[], isAudiobook: boolean | undefined): CoverSearchResult[] {
|
||||
return results.filter((r) => {
|
||||
if (r.width < 350) return false;
|
||||
if (isAudiobook) {
|
||||
const ratio = r.width / r.height;
|
||||
return ratio >= 0.85 && ratio <= 1.15;
|
||||
} else {
|
||||
return r.width < r.height;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private determineSource(url: string): string {
|
||||
if (url.includes('amazon.com')) return 'Amazon';
|
||||
if (url.includes('goodreads.com')) return 'Goodreads';
|
||||
if (url.includes('google.com')) return 'Google';
|
||||
try {
|
||||
return new URL(url).hostname.replace('www.', '');
|
||||
} catch {
|
||||
return 'Web';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user