mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-10-04 11:19:29 +02:00
Replace cover search with streaming version
This commit is contained in:
parent
a164c17d38
commit
7630dbdcb7
@ -59,11 +59,13 @@
|
|||||||
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 grow p-1">
|
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 grow p-1">
|
||||||
<ui-text-input-with-label v-model="searchAuthor" :label="$strings.LabelAuthor" />
|
<ui-text-input-with-label v-model="searchAuthor" :label="$strings.LabelAuthor" />
|
||||||
</div>
|
</div>
|
||||||
<ui-btn class="mt-5 ml-1 md:min-w-24" :padding-x="4" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
|
<ui-btn v-if="!searchInProgress" class="mt-5 ml-1 md:min-w-24" :padding-x="4" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
|
||||||
|
<ui-btn v-else class="mt-5 ml-1 md:min-w-24" :padding-x="4" type="button" color="bg-error" @click.prevent="cancelCurrentSearch">{{ $strings.ButtonCancel }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center sm:max-h-80 sm:overflow-y-scroll mt-2 max-w-full">
|
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center sm:max-h-80 sm:overflow-y-scroll mt-2 max-w-full">
|
||||||
<p v-if="!coversFound.length">{{ $strings.MessageNoCoversFound }}</p>
|
<p v-if="searchInProgress && !coversFound.length" class="text-gray-300">{{ $strings.MessageLoading }}</p>
|
||||||
|
<p v-else-if="!searchInProgress && !coversFound.length">{{ $strings.MessageNoCoversFound }}</p>
|
||||||
<template v-for="cover in coversFound">
|
<template v-for="cover in coversFound">
|
||||||
<div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === coverPath ? 'border-yellow-300' : ''" @click="updateCover(cover)">
|
<div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === coverPath ? 'border-yellow-300' : ''" @click="updateCover(cover)">
|
||||||
<covers-preview-cover :src="cover" :width="80" show-open-new-tab :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-preview-cover :src="cover" :width="80" show-open-new-tab :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
@ -105,7 +107,10 @@ export default {
|
|||||||
showLocalCovers: false,
|
showLocalCovers: false,
|
||||||
previewUpload: null,
|
previewUpload: null,
|
||||||
selectedFile: null,
|
selectedFile: null,
|
||||||
provider: 'google'
|
provider: 'google',
|
||||||
|
currentSearchRequestId: null,
|
||||||
|
searchInProgress: false,
|
||||||
|
socketListenersActive: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@ -186,6 +191,9 @@ export default {
|
|||||||
_file.localPath = `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${file.ino}?token=${this.userToken}`
|
_file.localPath = `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${file.ino}?token=${this.userToken}`
|
||||||
return _file
|
return _file
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
socket() {
|
||||||
|
return this.$root.socket
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@ -291,22 +299,123 @@ export default {
|
|||||||
console.error('PersistProvider', error)
|
console.error('PersistProvider', error)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
generateRequestId() {
|
||||||
|
return `cover-search-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
},
|
||||||
|
addSocketListeners() {
|
||||||
|
if (!this.socket || this.socketListenersActive) return
|
||||||
|
|
||||||
|
this.socket.on('cover_search_result', this.handleSearchResult)
|
||||||
|
this.socket.on('cover_search_complete', this.handleSearchComplete)
|
||||||
|
this.socket.on('cover_search_error', this.handleSearchError)
|
||||||
|
this.socket.on('cover_search_provider_error', this.handleProviderError)
|
||||||
|
this.socket.on('cover_search_cancelled', this.handleSearchCancelled)
|
||||||
|
this.socket.on('disconnect', this.handleSocketDisconnect)
|
||||||
|
this.socketListenersActive = true
|
||||||
|
},
|
||||||
|
removeSocketListeners() {
|
||||||
|
if (!this.socket || !this.socketListenersActive) return
|
||||||
|
|
||||||
|
this.socket.off('cover_search_result', this.handleSearchResult)
|
||||||
|
this.socket.off('cover_search_complete', this.handleSearchComplete)
|
||||||
|
this.socket.off('cover_search_error', this.handleSearchError)
|
||||||
|
this.socket.off('cover_search_provider_error', this.handleProviderError)
|
||||||
|
this.socket.off('cover_search_cancelled', this.handleSearchCancelled)
|
||||||
|
this.socket.off('disconnect', this.handleSocketDisconnect)
|
||||||
|
this.socketListenersActive = false
|
||||||
|
},
|
||||||
|
handleSearchResult(data) {
|
||||||
|
if (data.requestId !== this.currentSearchRequestId) return
|
||||||
|
|
||||||
|
console.log(`[Cover Search] Received ${data.total} covers from ${data.provider}`)
|
||||||
|
|
||||||
|
// Add new covers to the list (avoiding duplicates)
|
||||||
|
const newCovers = data.covers.filter((cover) => !this.coversFound.includes(cover))
|
||||||
|
this.coversFound.push(...newCovers)
|
||||||
|
},
|
||||||
|
handleSearchComplete(data) {
|
||||||
|
if (data.requestId !== this.currentSearchRequestId) return
|
||||||
|
|
||||||
|
console.log('[Cover Search] Search completed')
|
||||||
|
this.searchInProgress = false
|
||||||
|
this.currentSearchRequestId = null
|
||||||
|
},
|
||||||
|
handleSearchError(data) {
|
||||||
|
if (data.requestId !== this.currentSearchRequestId) return
|
||||||
|
|
||||||
|
console.error('[Cover Search] Search error:', data.error)
|
||||||
|
this.$toast.error(`Search failed: ${data.error}`)
|
||||||
|
this.searchInProgress = false
|
||||||
|
this.currentSearchRequestId = null
|
||||||
|
},
|
||||||
|
handleProviderError(data) {
|
||||||
|
if (data.requestId !== this.currentSearchRequestId) return
|
||||||
|
|
||||||
|
console.warn(`[Cover Search] Provider ${data.provider} failed:`, data.error)
|
||||||
|
// Don't show toast for individual provider failures, just log them
|
||||||
|
},
|
||||||
|
handleSearchCancelled(data) {
|
||||||
|
if (data.requestId !== this.currentSearchRequestId) return
|
||||||
|
|
||||||
|
console.log('[Cover Search] Search cancelled')
|
||||||
|
this.searchInProgress = false
|
||||||
|
this.currentSearchRequestId = null
|
||||||
|
},
|
||||||
|
handleSocketDisconnect() {
|
||||||
|
console.log('[Cover Search] Socket disconnected')
|
||||||
|
// If we were in the middle of a search, cancel it (server can't send results anymore)
|
||||||
|
if (this.searchInProgress && this.currentSearchRequestId) {
|
||||||
|
console.log('[Cover Search] Cancelling search due to socket disconnection')
|
||||||
|
this.searchInProgress = false
|
||||||
|
this.currentSearchRequestId = null
|
||||||
|
this.$toast.warning('Search was interrupted by connection loss')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancelCurrentSearch() {
|
||||||
|
if (!this.currentSearchRequestId || !this.socket) return
|
||||||
|
|
||||||
|
console.log('[Cover Search] Cancelling search:', this.currentSearchRequestId)
|
||||||
|
this.socket.emit('cancel_cover_search', this.currentSearchRequestId)
|
||||||
|
this.currentSearchRequestId = null
|
||||||
|
this.searchInProgress = false
|
||||||
|
},
|
||||||
async submitSearchForm() {
|
async submitSearchForm() {
|
||||||
|
// Cancel any existing search
|
||||||
|
if (this.searchInProgress) {
|
||||||
|
this.cancelCurrentSearch()
|
||||||
|
}
|
||||||
|
|
||||||
// Store provider in local storage
|
// Store provider in local storage
|
||||||
this.persistProvider()
|
this.persistProvider()
|
||||||
|
|
||||||
this.isProcessing = true
|
if (!this.socket) {
|
||||||
const searchQuery = this.getSearchQuery()
|
console.error('[Cover Search] Socket not available')
|
||||||
const results = await this.$axios
|
this.$toast.error('Connection not available. Please refresh the page.')
|
||||||
.$get(`/api/search/covers?${searchQuery}`)
|
return
|
||||||
.then((res) => res.results)
|
}
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed', error)
|
// Setup socket listeners if not already done
|
||||||
return []
|
this.addSocketListeners()
|
||||||
})
|
|
||||||
this.coversFound = results
|
// Clear previous results
|
||||||
this.isProcessing = false
|
this.coversFound = []
|
||||||
this.hasSearched = true
|
this.hasSearched = true
|
||||||
|
this.searchInProgress = true
|
||||||
|
|
||||||
|
// Generate unique request ID
|
||||||
|
const requestId = this.generateRequestId()
|
||||||
|
this.currentSearchRequestId = requestId
|
||||||
|
|
||||||
|
console.log('[Cover Search] Starting search:', requestId)
|
||||||
|
|
||||||
|
// Emit search request via WebSocket
|
||||||
|
this.socket.emit('search_covers', {
|
||||||
|
requestId,
|
||||||
|
title: this.searchTitle,
|
||||||
|
author: this.searchAuthor || '',
|
||||||
|
provider: this.provider,
|
||||||
|
podcast: this.isPodcast
|
||||||
|
})
|
||||||
},
|
},
|
||||||
setCover(coverFile) {
|
setCover(coverFile) {
|
||||||
this.isProcessing = true
|
this.isProcessing = true
|
||||||
@ -320,6 +429,18 @@ export default {
|
|||||||
this.isProcessing = false
|
this.isProcessing = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
// Setup socket listeners when component is mounted
|
||||||
|
this.addSocketListeners()
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
// Cancel any ongoing search when component is destroyed
|
||||||
|
if (this.searchInProgress) {
|
||||||
|
this.cancelCurrentSearch()
|
||||||
|
}
|
||||||
|
// Remove socket listeners
|
||||||
|
this.removeSocketListeners()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -2,6 +2,7 @@ const SocketIO = require('socket.io')
|
|||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
const Database = require('./Database')
|
const Database = require('./Database')
|
||||||
const TokenManager = require('./auth/TokenManager')
|
const TokenManager = require('./auth/TokenManager')
|
||||||
|
const CoverSearchManager = require('./managers/CoverSearchManager')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef SocketClient
|
* @typedef SocketClient
|
||||||
@ -180,6 +181,10 @@ class SocketAuthority {
|
|||||||
// Scanning
|
// Scanning
|
||||||
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
|
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
|
||||||
|
|
||||||
|
// Cover search streaming
|
||||||
|
socket.on('search_covers', (payload) => this.handleCoverSearch(socket, payload))
|
||||||
|
socket.on('cancel_cover_search', (requestId) => this.handleCancelCoverSearch(socket, requestId))
|
||||||
|
|
||||||
// Logs
|
// Logs
|
||||||
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
||||||
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
|
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
|
||||||
@ -200,6 +205,10 @@ class SocketAuthority {
|
|||||||
|
|
||||||
const disconnectTime = Date.now() - _client.connected_at
|
const disconnectTime = Date.now() - _client.connected_at
|
||||||
Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
|
Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
|
||||||
|
|
||||||
|
// Cancel any active cover searches for this socket
|
||||||
|
this.cancelSocketCoverSearches(socket.id)
|
||||||
|
|
||||||
delete this.clients[socket.id]
|
delete this.clients[socket.id]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -300,5 +309,100 @@ class SocketAuthority {
|
|||||||
Logger.debug('[SocketAuthority] Cancel scan', id)
|
Logger.debug('[SocketAuthority] Cancel scan', id)
|
||||||
this.Server.cancelLibraryScan(id)
|
this.Server.cancelLibraryScan(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle cover search request via WebSocket
|
||||||
|
* @param {SocketIO.Socket} socket
|
||||||
|
* @param {Object} payload
|
||||||
|
*/
|
||||||
|
async handleCoverSearch(socket, payload) {
|
||||||
|
const client = this.clients[socket.id]
|
||||||
|
if (!client?.user) {
|
||||||
|
Logger.error('[SocketAuthority] Unauthorized cover search request')
|
||||||
|
socket.emit('cover_search_error', {
|
||||||
|
requestId: payload.requestId,
|
||||||
|
error: 'Unauthorized'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { requestId, title, author, provider, podcast } = payload
|
||||||
|
|
||||||
|
if (!requestId || !title) {
|
||||||
|
Logger.error('[SocketAuthority] Invalid cover search request')
|
||||||
|
socket.emit('cover_search_error', {
|
||||||
|
requestId,
|
||||||
|
error: 'Invalid request parameters'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.info(`[SocketAuthority] User ${client.user.username} initiated cover search ${requestId}`)
|
||||||
|
|
||||||
|
// Callback for streaming results to client
|
||||||
|
const onResult = (result) => {
|
||||||
|
socket.emit('cover_search_result', {
|
||||||
|
requestId,
|
||||||
|
provider: result.provider,
|
||||||
|
covers: result.covers,
|
||||||
|
total: result.total
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback when search completes
|
||||||
|
const onComplete = () => {
|
||||||
|
Logger.info(`[SocketAuthority] Cover search ${requestId} completed`)
|
||||||
|
socket.emit('cover_search_complete', { requestId })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback for provider errors
|
||||||
|
const onError = (provider, errorMessage) => {
|
||||||
|
socket.emit('cover_search_provider_error', {
|
||||||
|
requestId,
|
||||||
|
provider,
|
||||||
|
error: errorMessage
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the search
|
||||||
|
CoverSearchManager.startSearch(requestId, { title, author, provider, podcast }, onResult, onComplete, onError).catch((error) => {
|
||||||
|
Logger.error(`[SocketAuthority] Cover search ${requestId} failed:`, error)
|
||||||
|
socket.emit('cover_search_error', {
|
||||||
|
requestId,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle cancel cover search request
|
||||||
|
* @param {SocketIO.Socket} socket
|
||||||
|
* @param {string} requestId
|
||||||
|
*/
|
||||||
|
handleCancelCoverSearch(socket, requestId) {
|
||||||
|
const client = this.clients[socket.id]
|
||||||
|
if (!client?.user) {
|
||||||
|
Logger.error('[SocketAuthority] Unauthorized cancel cover search request')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.info(`[SocketAuthority] User ${client.user.username} cancelled cover search ${requestId}`)
|
||||||
|
|
||||||
|
const cancelled = CoverSearchManager.cancelSearch(requestId)
|
||||||
|
if (cancelled) {
|
||||||
|
socket.emit('cover_search_cancelled', { requestId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel all cover searches associated with a socket (called on disconnect)
|
||||||
|
* @param {string} socketId
|
||||||
|
*/
|
||||||
|
cancelSocketCoverSearches(socketId) {
|
||||||
|
// Get all active search request IDs and cancel those that might belong to this socket
|
||||||
|
// Since we don't track socket-to-request mapping, we log this for debugging
|
||||||
|
// The client will handle reconnection gracefully
|
||||||
|
Logger.debug(`[SocketAuthority] Socket ${socketId} disconnected, any active searches will timeout`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = new SocketAuthority()
|
module.exports = new SocketAuthority()
|
||||||
|
248
server/managers/CoverSearchManager.js
Normal file
248
server/managers/CoverSearchManager.js
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
const { setMaxListeners } = require('events')
|
||||||
|
const Logger = require('../Logger')
|
||||||
|
const BookFinder = require('../finders/BookFinder')
|
||||||
|
const PodcastFinder = require('../finders/PodcastFinder')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager for handling streaming cover search across multiple providers
|
||||||
|
*/
|
||||||
|
class CoverSearchManager {
|
||||||
|
constructor() {
|
||||||
|
/** @type {Map<string, AbortController>} Map of requestId to AbortController */
|
||||||
|
this.activeSearches = new Map()
|
||||||
|
|
||||||
|
// Default timeout for each provider search
|
||||||
|
this.providerTimeout = 10000 // 10 seconds
|
||||||
|
|
||||||
|
// Set to 0 to disable the max listeners limit
|
||||||
|
// We need one listener per provider (15+) and may have multiple concurrent searches
|
||||||
|
this.maxListeners = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a streaming cover search
|
||||||
|
* @param {string} requestId - Unique identifier for this search request
|
||||||
|
* @param {Object} searchParams - Search parameters
|
||||||
|
* @param {string} searchParams.title - Title to search for
|
||||||
|
* @param {string} searchParams.author - Author to search for (optional)
|
||||||
|
* @param {string} searchParams.provider - Provider to search (or 'all')
|
||||||
|
* @param {boolean} searchParams.podcast - Whether this is a podcast search
|
||||||
|
* @param {Function} onResult - Callback for each result chunk
|
||||||
|
* @param {Function} onComplete - Callback when search completes
|
||||||
|
* @param {Function} onError - Callback for errors
|
||||||
|
*/
|
||||||
|
async startSearch(requestId, searchParams, onResult, onComplete, onError) {
|
||||||
|
if (this.activeSearches.has(requestId)) {
|
||||||
|
Logger.warn(`[CoverSearchManager] Search with requestId ${requestId} already exists`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController()
|
||||||
|
|
||||||
|
// Increase max listeners on this signal to accommodate parallel provider searches
|
||||||
|
// AbortSignal is an EventTarget, so we use the events module's setMaxListeners
|
||||||
|
setMaxListeners(this.maxListeners, abortController.signal)
|
||||||
|
|
||||||
|
this.activeSearches.set(requestId, abortController)
|
||||||
|
|
||||||
|
Logger.info(`[CoverSearchManager] Starting search ${requestId} with params:`, searchParams)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { title, author, provider, podcast } = searchParams
|
||||||
|
|
||||||
|
if (podcast) {
|
||||||
|
await this.searchPodcastCovers(requestId, title, abortController.signal, onResult, onError)
|
||||||
|
} else {
|
||||||
|
await this.searchBookCovers(requestId, provider, title, author, abortController.signal, onResult, onError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
Logger.info(`[CoverSearchManager] Search ${requestId} was cancelled`)
|
||||||
|
} else {
|
||||||
|
Logger.error(`[CoverSearchManager] Search ${requestId} failed:`, error)
|
||||||
|
onError(error.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.activeSearches.delete(requestId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel an active search
|
||||||
|
* @param {string} requestId - Request ID to cancel
|
||||||
|
*/
|
||||||
|
cancelSearch(requestId) {
|
||||||
|
const abortController = this.activeSearches.get(requestId)
|
||||||
|
if (abortController) {
|
||||||
|
Logger.info(`[CoverSearchManager] Cancelling search ${requestId}`)
|
||||||
|
abortController.abort()
|
||||||
|
this.activeSearches.delete(requestId)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for podcast covers
|
||||||
|
*/
|
||||||
|
async searchPodcastCovers(requestId, title, signal, onResult, onError) {
|
||||||
|
try {
|
||||||
|
const results = await this.executeWithTimeout(() => PodcastFinder.findCovers(title), this.providerTimeout, signal)
|
||||||
|
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
const covers = this.extractCoversFromResults(results)
|
||||||
|
if (covers.length > 0) {
|
||||||
|
onResult({
|
||||||
|
provider: 'itunes',
|
||||||
|
covers,
|
||||||
|
total: covers.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name !== 'AbortError') {
|
||||||
|
Logger.error(`[CoverSearchManager] Podcast search failed:`, error)
|
||||||
|
onError('itunes', error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for book covers across providers
|
||||||
|
*/
|
||||||
|
async searchBookCovers(requestId, provider, title, author, signal, onResult, onError) {
|
||||||
|
let providers = []
|
||||||
|
|
||||||
|
if (provider === 'all') {
|
||||||
|
providers = [...BookFinder.providers]
|
||||||
|
} else {
|
||||||
|
providers = [provider]
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.debug(`[CoverSearchManager] Searching ${providers.length} providers in parallel`)
|
||||||
|
|
||||||
|
// Search all providers in parallel
|
||||||
|
const searchPromises = providers.map(async (providerName) => {
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const searchResults = await this.executeWithTimeout(() => BookFinder.search(null, providerName, title, author || ''), this.providerTimeout, signal)
|
||||||
|
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
const covers = this.extractCoversFromResults(searchResults)
|
||||||
|
|
||||||
|
Logger.debug(`[CoverSearchManager] Found ${covers.length} covers from ${providerName}`)
|
||||||
|
|
||||||
|
if (covers.length > 0) {
|
||||||
|
onResult({
|
||||||
|
provider: providerName,
|
||||||
|
covers,
|
||||||
|
total: covers.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name !== 'AbortError') {
|
||||||
|
Logger.warn(`[CoverSearchManager] Provider ${providerName} failed:`, error.message)
|
||||||
|
onError(providerName, error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.allSettled(searchPromises)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a promise with timeout and abort signal
|
||||||
|
*/
|
||||||
|
async executeWithTimeout(fn, timeout, signal) {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
let abortHandler = null
|
||||||
|
let timeoutId = null
|
||||||
|
|
||||||
|
// Cleanup function to ensure we always remove listeners
|
||||||
|
const cleanup = () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
timeoutId = null
|
||||||
|
}
|
||||||
|
if (abortHandler) {
|
||||||
|
signal.removeEventListener('abort', abortHandler)
|
||||||
|
abortHandler = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up timeout
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
cleanup()
|
||||||
|
const error = new Error('Provider timeout')
|
||||||
|
error.name = 'TimeoutError'
|
||||||
|
reject(error)
|
||||||
|
}, timeout)
|
||||||
|
|
||||||
|
// Check if already aborted
|
||||||
|
if (signal.aborted) {
|
||||||
|
cleanup()
|
||||||
|
const error = new Error('Search cancelled')
|
||||||
|
error.name = 'AbortError'
|
||||||
|
reject(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up abort handler
|
||||||
|
abortHandler = () => {
|
||||||
|
cleanup()
|
||||||
|
const error = new Error('Search cancelled')
|
||||||
|
error.name = 'AbortError'
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
signal.addEventListener('abort', abortHandler)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fn()
|
||||||
|
cleanup()
|
||||||
|
resolve(result)
|
||||||
|
} catch (error) {
|
||||||
|
cleanup()
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract cover URLs from search results
|
||||||
|
*/
|
||||||
|
extractCoversFromResults(results) {
|
||||||
|
const covers = []
|
||||||
|
if (!Array.isArray(results)) return covers
|
||||||
|
|
||||||
|
results.forEach((result) => {
|
||||||
|
if (result.covers && Array.isArray(result.covers)) {
|
||||||
|
covers.push(...result.covers)
|
||||||
|
}
|
||||||
|
if (result.cover) {
|
||||||
|
covers.push(result.cover)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove duplicates
|
||||||
|
return [...new Set(covers)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel all active searches (cleanup on server shutdown)
|
||||||
|
*/
|
||||||
|
cancelAllSearches() {
|
||||||
|
Logger.info(`[CoverSearchManager] Cancelling ${this.activeSearches.size} active searches`)
|
||||||
|
for (const [requestId, abortController] of this.activeSearches.entries()) {
|
||||||
|
abortController.abort()
|
||||||
|
}
|
||||||
|
this.activeSearches.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new CoverSearchManager()
|
Loading…
Reference in New Issue
Block a user