feat(cover): add online cover search with provider infrastructure

This commit is contained in:
neon
2026-03-06 04:21:48 -06:00
parent ca7a1b0095
commit 78051d0b42
9 changed files with 461 additions and 6 deletions
@@ -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>
+1
View File
@@ -169,6 +169,7 @@ const activeFilterCount = computed(() => filter.value?.rules?.length ?? 0)
function clearFilters() {
filter.value = undefined
forgetSavedFilter()
}
const sentinel = ref<HTMLElement | null>(null)
+14
View File
@@ -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;
};
+33 -2
View File
@@ -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')
+2 -1
View File
@@ -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 {}
+34 -1
View File
@@ -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';
}
}
}