audiobookshelf/server/controllers/SearchController.js

239 lines
7.7 KiB
JavaScript

const { Request, Response } = require('express')
const Logger = require('../Logger')
const BookFinder = require('../finders/BookFinder')
const PodcastFinder = require('../finders/PodcastFinder')
const AuthorFinder = require('../finders/AuthorFinder')
const Database = require('../Database')
const { isValidASIN, getQueryParamAsString, ValidationError, NotFoundError } = require('../utils')
// Provider name mappings for display purposes
const providerMap = {
all: 'All',
best: 'Best',
google: 'Google Books',
itunes: 'iTunes',
openlibrary: 'Open Library',
fantlab: 'FantLab.ru',
audiobookcovers: 'AudiobookCovers.com',
audible: 'Audible.com',
'audible.ca': 'Audible.ca',
'audible.uk': 'Audible.co.uk',
'audible.au': 'Audible.com.au',
'audible.fr': 'Audible.fr',
'audible.de': 'Audible.de',
'audible.jp': 'Audible.co.jp',
'audible.it': 'Audible.it',
'audible.in': 'Audible.in',
'audible.es': 'Audible.es',
audnexus: 'Audnexus'
}
/**
* @typedef RequestUserObject
* @property {import('../models/User')} user
*
* @typedef {Request & RequestUserObject} RequestWithUser
*/
class SearchController {
constructor() {}
/**
* Fetches a library item by ID
* @param {string} id - Library item ID
* @param {string} methodName - Name of the calling method for logging
* @returns {Promise<LibraryItem>}
*/
static async fetchLibraryItem(id) {
const libraryItem = await Database.libraryItemModel.getExpandedById(id)
if (!libraryItem) {
throw new NotFoundError(`library item "${id}" not found`)
}
return libraryItem
}
/**
* Maps custom metadata providers to standardized format
* @param {Array} providers - Array of custom provider objects
* @returns {Array<{value: string, text: string}>}
*/
static mapCustomProviders(providers) {
return providers.map((provider) => ({
value: provider.getSlug(),
text: provider.name
}))
}
/**
* Static helper method to format provider for client (for use in array methods)
* @param {string} providerValue - Provider identifier
* @returns {{value: string, text: string}}
*/
static formatProvider(providerValue) {
return {
value: providerValue,
text: providerMap[providerValue] || providerValue
}
}
/**
* GET: /api/search/books
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async findBooks(req, res) {
try {
const query = req.query
const provider = getQueryParamAsString(query, 'provider', 'google')
const title = getQueryParamAsString(query, 'title', '')
const author = getQueryParamAsString(query, 'author', '')
const id = getQueryParamAsString(query, 'id', '', true)
// Fetch library item
const libraryItem = await SearchController.fetchLibraryItem(id)
const results = await BookFinder.search(libraryItem, provider, title, author)
res.json(results)
} catch (error) {
Logger.error(`[SearchController] findBooks: ${error.message}`)
if (error instanceof ValidationError || error instanceof NotFoundError) {
return res.status(error.status).send(error.message)
}
return res.status(500).send('Internal server error')
}
}
/**
* GET: /api/search/covers
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async findCovers(req, res) {
try {
const query = req.query
const podcast = query.podcast === '1' || query.podcast === 1
const title = getQueryParamAsString(query, 'title', '', true)
const author = getQueryParamAsString(query, 'author', '')
const provider = getQueryParamAsString(query, 'provider', 'google')
let results = null
if (podcast) results = await PodcastFinder.findCovers(title)
else results = await BookFinder.findCovers(provider, title, author)
res.json({ results })
} catch (error) {
Logger.error(`[SearchController] findCovers: ${error.message}`)
if (error instanceof ValidationError) {
return res.status(error.status).send(error.message)
}
return res.status(500).send('Internal server error')
}
}
/**
* GET: /api/search/podcasts
* Find podcast RSS feeds given a term
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async findPodcasts(req, res) {
try {
const query = req.query
const term = getQueryParamAsString(query, 'term', '', true)
const country = getQueryParamAsString(query, 'country', 'us')
const results = await PodcastFinder.search(term, { country })
res.json(results)
} catch (error) {
Logger.error(`[SearchController] findPodcasts: ${error.message}`)
if (error instanceof ValidationError) {
return res.status(error.status).send(error.message)
}
return res.status(500).send('Internal server error')
}
}
/**
* GET: /api/search/authors
* Note: This endpoint is not currently used in the web client.
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async findAuthor(req, res) {
try {
const query = getQueryParamAsString(req.query, 'q', '', true)
const author = await AuthorFinder.findAuthorByName(query)
res.json(author)
} catch (error) {
Logger.error(`[SearchController] findAuthor: ${error.message}`)
if (error instanceof ValidationError) {
return res.status(error.status).send(error.message)
}
return res.status(500).send('Internal server error')
}
}
/**
* GET: /api/search/chapters
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async findChapters(req, res) {
try {
const query = req.query
const asin = getQueryParamAsString(query, 'asin', '', true)
const region = getQueryParamAsString(req.query.region, 'us').toLowerCase()
if (!isValidASIN(asin.toUpperCase())) throw new ValidationError('asin', 'is invalid')
const chapterData = await BookFinder.findChapters(asin, region)
if (!chapterData) {
return res.json({ error: 'Chapters not found', stringKey: 'MessageChaptersNotFound' })
}
res.json(chapterData)
} catch (error) {
Logger.error(`[SearchController] findChapters: ${error.message}`)
if (error instanceof ValidationError) {
if (error.paramName === 'asin') {
return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' })
}
if (error.paramName === 'region') {
return res.json({ error: 'Invalid region', stringKey: 'MessageInvalidRegion' })
}
}
return res.status(500).send('Internal server error')
}
}
/**
* GET: /api/search/providers
* Get all available metadata providers
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async getAllProviders(req, res) {
const customProviders = await Database.customMetadataProviderModel.findAll()
const customBookProviders = customProviders.filter((p) => p.mediaType === 'book')
const customPodcastProviders = customProviders.filter((p) => p.mediaType === 'podcast')
const bookProviders = BookFinder.providers.filter((p) => p !== 'audiobookcovers')
// Build minimized payload with custom providers merged in
const providers = {
books: [...bookProviders.map((p) => SearchController.formatProvider(p)), ...SearchController.mapCustomProviders(customBookProviders)],
booksCovers: [SearchController.formatProvider('best'), ...BookFinder.providers.map((p) => SearchController.formatProvider(p)), ...SearchController.mapCustomProviders(customBookProviders), SearchController.formatProvider('all')],
podcasts: [SearchController.formatProvider('itunes'), ...SearchController.mapCustomProviders(customPodcastProviders)]
}
res.json({ providers })
}
}
module.exports = new SearchController()