mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-11-10 01:19:37 +01:00
Merge pull request #4750 from mikiher/providers-api
Add metadata providers API and use them on web client
This commit is contained in:
commit
a92ba564bd
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@ -60,7 +60,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@ -73,6 +73,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
@ -88,7 +88,7 @@ export default {
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
return this.$store.state.scanners.bookProviders
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
@ -96,6 +96,9 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
// Fetch providers when modal is shown
|
||||
this.$store.dispatch('scanners/fetchProviders')
|
||||
|
||||
// If we don't have a set provider (first open of dialog) or we've switched library, set
|
||||
// the selected provider to the current library default provider
|
||||
if (!this.options.provider || this.lastUsedLibrary != this.currentLibraryId) {
|
||||
@ -127,8 +130,7 @@ export default {
|
||||
this.show = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -133,8 +133,8 @@ export default {
|
||||
}
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return [{ text: 'Best', value: 'best' }, ...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders, { text: 'All', value: 'all' }]
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastCoverProviders
|
||||
return this.$store.state.scanners.bookCoverProviders
|
||||
},
|
||||
searchTitleLabel() {
|
||||
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
||||
@ -438,6 +438,8 @@ export default {
|
||||
mounted() {
|
||||
// Setup socket listeners when component is mounted
|
||||
this.addSocketListeners()
|
||||
// Fetch providers if not already loaded
|
||||
this.$store.dispatch('scanners/fetchProviders')
|
||||
},
|
||||
beforeDestroy() {
|
||||
// Cancel any ongoing search when component is destroyed
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<div id="match-wrapper" class="w-full h-full overflow-hidden px-2 md:px-4 py-4 md:py-6 relative">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<div class="flex flex-wrap md:flex-nowrap items-center justify-start -mx-1">
|
||||
<div class="w-36 px-1">
|
||||
<div v-if="providersLoaded" class="w-36 px-1">
|
||||
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
|
||||
</div>
|
||||
<div class="grow md:w-72 px-1">
|
||||
@ -253,6 +253,7 @@ export default {
|
||||
hasSearched: false,
|
||||
selectedMatch: null,
|
||||
selectedMatchOrig: null,
|
||||
waitingForProviders: false,
|
||||
selectedMatchUsage: {
|
||||
title: true,
|
||||
subtitle: true,
|
||||
@ -285,9 +286,19 @@ export default {
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
},
|
||||
providersLoaded(isLoaded) {
|
||||
// Complete initialization once providers are loaded
|
||||
if (isLoaded && this.waitingForProviders) {
|
||||
this.waitingForProviders = false
|
||||
this.initProviderAndSearch()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
providersLoaded() {
|
||||
return this.$store.getters['scanners/areProvidersLoaded']
|
||||
},
|
||||
isProcessing: {
|
||||
get() {
|
||||
return this.processing
|
||||
@ -319,7 +330,7 @@ export default {
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
return this.$store.state.scanners.bookProviders
|
||||
},
|
||||
searchTitleLabel() {
|
||||
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
||||
@ -478,6 +489,24 @@ export default {
|
||||
|
||||
this.checkboxToggled()
|
||||
},
|
||||
initProviderAndSearch() {
|
||||
// Set provider based on media type
|
||||
if (this.isPodcast) {
|
||||
this.provider = 'itunes'
|
||||
} else {
|
||||
this.provider = this.getDefaultBookProvider()
|
||||
}
|
||||
|
||||
// Prefer using ASIN if set and using audible provider
|
||||
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
|
||||
this.searchTitle = this.libraryItem.media.metadata.asin
|
||||
this.searchAuthor = ''
|
||||
}
|
||||
|
||||
if (this.searchTitle) {
|
||||
this.submitSearch()
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.clearSelectedMatch()
|
||||
this.initSelectedMatchUsage()
|
||||
@ -495,19 +524,13 @@ export default {
|
||||
}
|
||||
this.searchTitle = this.libraryItem.media.metadata.title
|
||||
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
|
||||
if (this.isPodcast) this.provider = 'itunes'
|
||||
else {
|
||||
this.provider = this.getDefaultBookProvider()
|
||||
}
|
||||
|
||||
// Prefer using ASIN if set and using audible provider
|
||||
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
|
||||
this.searchTitle = this.libraryItem.media.metadata.asin
|
||||
this.searchAuthor = ''
|
||||
}
|
||||
|
||||
if (this.searchTitle) {
|
||||
this.submitSearch()
|
||||
// Wait for providers to be loaded before setting provider and searching
|
||||
if (this.providersLoaded || this.isPodcast) {
|
||||
this.waitingForProviders = false
|
||||
this.initProviderAndSearch()
|
||||
} else {
|
||||
this.waitingForProviders = true
|
||||
}
|
||||
},
|
||||
selectMatch(match) {
|
||||
@ -637,6 +660,10 @@ export default {
|
||||
this.selectedMatch = null
|
||||
this.selectedMatchOrig = null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Fetch providers if not already loaded
|
||||
this.$store.dispatch('scanners/fetchProviders')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -74,7 +74,7 @@ export default {
|
||||
},
|
||||
providers() {
|
||||
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
return this.$store.state.scanners.bookProviders
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@ -156,6 +156,8 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
// Fetch providers if not already loaded
|
||||
this.$store.dispatch('scanners/fetchProviders')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -104,7 +104,6 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
provider: null,
|
||||
useSquareBookCovers: false,
|
||||
enableWatcher: false,
|
||||
skipMatchingMediaWithAsin: false,
|
||||
@ -134,10 +133,6 @@ export default {
|
||||
isPodcastLibrary() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
providers() {
|
||||
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
},
|
||||
maskAsFinishedWhenItems() {
|
||||
return [
|
||||
{
|
||||
|
||||
@ -371,11 +371,13 @@ export default {
|
||||
},
|
||||
customMetadataProviderAdded(provider) {
|
||||
if (!provider?.id) return
|
||||
this.$store.commit('scanners/addCustomMetadataProvider', provider)
|
||||
// Refresh providers cache
|
||||
this.$store.dispatch('scanners/refreshProviders')
|
||||
},
|
||||
customMetadataProviderRemoved(provider) {
|
||||
if (!provider?.id) return
|
||||
this.$store.commit('scanners/removeCustomMetadataProvider', provider)
|
||||
// Refresh providers cache
|
||||
this.$store.dispatch('scanners/refreshProviders')
|
||||
},
|
||||
initializeSocket() {
|
||||
if (this.$root.socket) {
|
||||
|
||||
@ -247,7 +247,8 @@ export default {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
providers() {
|
||||
return this.$store.state.scanners.providers
|
||||
// Use book cover providers for the cover provider dropdown
|
||||
return this.$store.state.scanners.bookCoverProviders || []
|
||||
},
|
||||
dateFormats() {
|
||||
return this.$store.state.globals.dateFormats
|
||||
@ -416,6 +417,8 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.initServerSettings()
|
||||
// Fetch providers if not already loaded (for cover provider dropdown)
|
||||
this.$store.dispatch('scanners/fetchProviders')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -155,7 +155,7 @@ export default {
|
||||
},
|
||||
providers() {
|
||||
if (this.selectedLibraryIsPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
return this.$store.state.scanners.bookProviders
|
||||
},
|
||||
canFetchMetadata() {
|
||||
return !this.selectedLibraryIsPodcast && this.fetchMetadata.enabled
|
||||
@ -394,6 +394,8 @@ export default {
|
||||
this.setMetadataProvider()
|
||||
|
||||
this.setDefaultFolder()
|
||||
// Fetch providers if not already loaded
|
||||
this.$store.dispatch('scanners/fetchProviders')
|
||||
window.addEventListener('dragenter', this.dragenter)
|
||||
window.addEventListener('dragleave', this.dragleave)
|
||||
window.addEventListener('dragover', this.dragover)
|
||||
|
||||
@ -117,7 +117,6 @@ export const actions = {
|
||||
const library = data.library
|
||||
const filterData = data.filterdata
|
||||
const issues = data.issues || 0
|
||||
const customMetadataProviders = data.customMetadataProviders || []
|
||||
const numUserPlaylists = data.numUserPlaylists
|
||||
|
||||
dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true })
|
||||
@ -131,8 +130,6 @@ export const actions = {
|
||||
commit('setLibraryIssues', issues)
|
||||
commit('setLibraryFilterData', filterData)
|
||||
commit('setNumUserPlaylists', numUserPlaylists)
|
||||
commit('scanners/setCustomMetadataProviders', customMetadataProviders, { root: true })
|
||||
|
||||
commit('setCurrentLibrary', { id: libraryId })
|
||||
return data
|
||||
})
|
||||
|
||||
@ -1,126 +1,60 @@
|
||||
export const state = () => ({
|
||||
providers: [
|
||||
{
|
||||
text: 'Google Books',
|
||||
value: 'google'
|
||||
},
|
||||
{
|
||||
text: 'Open Library',
|
||||
value: 'openlibrary'
|
||||
},
|
||||
{
|
||||
text: 'iTunes',
|
||||
value: 'itunes'
|
||||
},
|
||||
{
|
||||
text: 'Audible.com',
|
||||
value: 'audible'
|
||||
},
|
||||
{
|
||||
text: 'Audible.ca',
|
||||
value: 'audible.ca'
|
||||
},
|
||||
{
|
||||
text: 'Audible.co.uk',
|
||||
value: 'audible.uk'
|
||||
},
|
||||
{
|
||||
text: 'Audible.com.au',
|
||||
value: 'audible.au'
|
||||
},
|
||||
{
|
||||
text: 'Audible.fr',
|
||||
value: 'audible.fr'
|
||||
},
|
||||
{
|
||||
text: 'Audible.de',
|
||||
value: 'audible.de'
|
||||
},
|
||||
{
|
||||
text: 'Audible.co.jp',
|
||||
value: 'audible.jp'
|
||||
},
|
||||
{
|
||||
text: 'Audible.it',
|
||||
value: 'audible.it'
|
||||
},
|
||||
{
|
||||
text: 'Audible.co.in',
|
||||
value: 'audible.in'
|
||||
},
|
||||
{
|
||||
text: 'Audible.es',
|
||||
value: 'audible.es'
|
||||
},
|
||||
{
|
||||
text: 'FantLab.ru',
|
||||
value: 'fantlab'
|
||||
}
|
||||
],
|
||||
podcastProviders: [
|
||||
{
|
||||
text: 'iTunes',
|
||||
value: 'itunes'
|
||||
}
|
||||
],
|
||||
coverOnlyProviders: [
|
||||
{
|
||||
text: 'AudiobookCovers.com',
|
||||
value: 'audiobookcovers'
|
||||
}
|
||||
]
|
||||
bookProviders: [],
|
||||
podcastProviders: [],
|
||||
bookCoverProviders: [],
|
||||
podcastCoverProviders: [],
|
||||
providersLoaded: false
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
checkBookProviderExists: state => (providerValue) => {
|
||||
return state.providers.some(p => p.value === providerValue)
|
||||
checkBookProviderExists: (state) => (providerValue) => {
|
||||
return state.bookProviders.some((p) => p.value === providerValue)
|
||||
},
|
||||
checkPodcastProviderExists: state => (providerValue) => {
|
||||
return state.podcastProviders.some(p => p.value === providerValue)
|
||||
checkPodcastProviderExists: (state) => (providerValue) => {
|
||||
return state.podcastProviders.some((p) => p.value === providerValue)
|
||||
},
|
||||
areProvidersLoaded: (state) => state.providersLoaded
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
async fetchProviders({ commit, state }) {
|
||||
// Only fetch if not already loaded
|
||||
if (state.providersLoaded) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.$axios.$get('/api/search/providers')
|
||||
if (response?.providers) {
|
||||
commit('setAllProviders', response.providers)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch providers', error)
|
||||
}
|
||||
},
|
||||
async refreshProviders({ commit, state }) {
|
||||
// if providers are not loaded, do nothing - they will be fetched when required (
|
||||
if (!state.providersLoaded) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.$axios.$get('/api/search/providers')
|
||||
if (response?.providers) {
|
||||
commit('setAllProviders', response.providers)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh providers', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {}
|
||||
|
||||
export const mutations = {
|
||||
addCustomMetadataProvider(state, provider) {
|
||||
if (provider.mediaType === 'book') {
|
||||
if (state.providers.some(p => p.value === provider.slug)) return
|
||||
state.providers.push({
|
||||
text: provider.name,
|
||||
value: provider.slug
|
||||
})
|
||||
} else {
|
||||
if (state.podcastProviders.some(p => p.value === provider.slug)) return
|
||||
state.podcastProviders.push({
|
||||
text: provider.name,
|
||||
value: provider.slug
|
||||
})
|
||||
}
|
||||
},
|
||||
removeCustomMetadataProvider(state, provider) {
|
||||
if (provider.mediaType === 'book') {
|
||||
state.providers = state.providers.filter(p => p.value !== provider.slug)
|
||||
} else {
|
||||
state.podcastProviders = state.podcastProviders.filter(p => p.value !== provider.slug)
|
||||
}
|
||||
},
|
||||
setCustomMetadataProviders(state, providers) {
|
||||
if (!providers?.length) return
|
||||
|
||||
const mediaType = providers[0].mediaType
|
||||
if (mediaType === 'book') {
|
||||
// clear previous values, and add new values to the end
|
||||
state.providers = state.providers.filter((p) => !p.value.startsWith('custom-'))
|
||||
state.providers = [
|
||||
...state.providers,
|
||||
...providers.map((p) => ({
|
||||
text: p.name,
|
||||
value: p.slug
|
||||
}))
|
||||
]
|
||||
} else {
|
||||
// Podcast providers not supported yet
|
||||
}
|
||||
setAllProviders(state, providers) {
|
||||
state.bookProviders = providers.books || []
|
||||
state.podcastProviders = providers.podcasts || []
|
||||
state.bookCoverProviders = providers.booksCovers || []
|
||||
state.podcastCoverProviders = providers.podcasts || [] // Use same as bookCovers since podcasts use iTunes only
|
||||
state.providersLoaded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -221,13 +221,11 @@ class LibraryController {
|
||||
const includeArray = (req.query.include || '').split(',')
|
||||
if (includeArray.includes('filterdata')) {
|
||||
const filterdata = await libraryFilters.getFilterData(req.library.mediaType, req.library.id)
|
||||
const customMetadataProviders = await Database.customMetadataProviderModel.getForClientByMediaType(req.library.mediaType)
|
||||
|
||||
return res.json({
|
||||
filterdata,
|
||||
issues: filterdata.numIssues,
|
||||
numUserPlaylists: await Database.playlistModel.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id),
|
||||
customMetadataProviders,
|
||||
library: req.library.toOldJSON()
|
||||
})
|
||||
}
|
||||
|
||||
@ -4,7 +4,29 @@ const BookFinder = require('../finders/BookFinder')
|
||||
const PodcastFinder = require('../finders/PodcastFinder')
|
||||
const AuthorFinder = require('../finders/AuthorFinder')
|
||||
const Database = require('../Database')
|
||||
const { isValidASIN } = require('../utils')
|
||||
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
|
||||
@ -16,6 +38,44 @@ const { isValidASIN } = require('../utils')
|
||||
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<import('../models/LibraryItem').LibraryItemExpanded>}
|
||||
*/
|
||||
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
|
||||
*
|
||||
@ -23,19 +83,25 @@ class SearchController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findBooks(req, res) {
|
||||
const id = req.query.id
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(id)
|
||||
const provider = req.query.provider || 'google'
|
||||
const title = req.query.title || ''
|
||||
const author = req.query.author || ''
|
||||
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)
|
||||
|
||||
if (typeof provider !== 'string' || typeof title !== 'string' || typeof author !== 'string') {
|
||||
Logger.error(`[SearchController] findBooks: Invalid request query params`)
|
||||
return res.status(400).send('Invalid request query params')
|
||||
// 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).json({ error: error.message })
|
||||
}
|
||||
return res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
|
||||
const results = await BookFinder.search(libraryItem, provider, title, author)
|
||||
res.json(results)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -45,20 +111,24 @@ class SearchController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findCovers(req, res) {
|
||||
const query = req.query
|
||||
const podcast = query.podcast == 1
|
||||
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')
|
||||
|
||||
if (!query.title || typeof query.title !== 'string') {
|
||||
Logger.error(`[SearchController] findCovers: Invalid title sent in query`)
|
||||
return res.sendStatus(400)
|
||||
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).json({ error: error.message })
|
||||
}
|
||||
return res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
|
||||
let results = null
|
||||
if (podcast) results = await PodcastFinder.findCovers(query.title)
|
||||
else results = await BookFinder.findCovers(query.provider || 'google', query.title, query.author || '')
|
||||
res.json({
|
||||
results
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@ -69,34 +139,42 @@ class SearchController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findPodcasts(req, res) {
|
||||
const term = req.query.term
|
||||
const country = req.query.country || 'us'
|
||||
if (!term) {
|
||||
Logger.error('[SearchController] Invalid request query param "term" is required')
|
||||
return res.status(400).send('Invalid request query param "term" is required')
|
||||
}
|
||||
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)
|
||||
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).json({ error: error.message })
|
||||
}
|
||||
return res.status(500).json({ error: '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) {
|
||||
const query = req.query.q
|
||||
if (!query || typeof query !== 'string') {
|
||||
Logger.error(`[SearchController] findAuthor: Invalid query param`)
|
||||
return res.status(400).send('Invalid query param')
|
||||
}
|
||||
try {
|
||||
const query = getQueryParamAsString(req.query, 'q', '', true)
|
||||
|
||||
const author = await AuthorFinder.findAuthorByName(query)
|
||||
res.json(author)
|
||||
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).json({ error: error.message })
|
||||
}
|
||||
return res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -106,16 +184,55 @@ class SearchController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findChapters(req, res) {
|
||||
const asin = req.query.asin
|
||||
if (!isValidASIN(asin.toUpperCase())) {
|
||||
return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' })
|
||||
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).json({ error: 'Internal server error' })
|
||||
}
|
||||
const region = (req.query.region || 'us').toLowerCase()
|
||||
const chapterData = await BookFinder.findChapters(asin, region)
|
||||
if (!chapterData) {
|
||||
return res.json({ error: 'Chapters not found', stringKey: 'MessageChaptersNotFound' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(chapterData)
|
||||
|
||||
res.json({ providers })
|
||||
}
|
||||
}
|
||||
module.exports = new SearchController()
|
||||
|
||||
@ -385,6 +385,11 @@ class BookFinder {
|
||||
|
||||
if (!title) return books
|
||||
|
||||
// Truncate excessively long inputs to prevent ReDoS attacks
|
||||
const MAX_INPUT_LENGTH = 500
|
||||
title = title.substring(0, MAX_INPUT_LENGTH)
|
||||
author = author?.substring(0, MAX_INPUT_LENGTH) || author
|
||||
|
||||
const isTitleAsin = isValidASIN(title.toUpperCase())
|
||||
|
||||
let actualTitleQuery = title
|
||||
@ -402,7 +407,8 @@ class BookFinder {
|
||||
let authorCandidates = new BookFinder.AuthorCandidates(cleanAuthor, this.audnexus)
|
||||
|
||||
// Remove underscores and parentheses with their contents, and replace with a separator
|
||||
const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}|_/g, ' - ')
|
||||
// Use negated character classes to prevent ReDoS vulnerability (input length validated at entry point)
|
||||
const cleanTitle = title.replace(/\[[^\]]*\]|\([^)]*\)|{[^}]*}|_/g, ' - ')
|
||||
// Split title into hypen-separated parts
|
||||
const titleParts = cleanTitle.split(/ - | -|- /)
|
||||
for (const titlePart of titleParts) authorCandidates.add(titlePart)
|
||||
@ -668,7 +674,9 @@ function cleanTitleForCompares(title, keepSubtitle = false) {
|
||||
let stripped = keepSubtitle ? title : stripSubtitle(title)
|
||||
|
||||
// Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game")
|
||||
let cleaned = stripped.replace(/ *\([^)]*\) */g, '')
|
||||
// Use negated character class to prevent ReDoS vulnerability (input length validated at entry point)
|
||||
let cleaned = stripped.replace(/\([^)]*\)/g, '') // Remove parenthetical content
|
||||
cleaned = cleaned.replace(/\s+/g, ' ').trim() // Clean up any resulting multiple spaces
|
||||
|
||||
// Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
|
||||
cleaned = cleaned.replace(/'/g, '')
|
||||
|
||||
@ -7,9 +7,9 @@ class PodcastFinder {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} term
|
||||
* @param {{country:string}} options
|
||||
*
|
||||
* @param {string} term
|
||||
* @param {{country:string}} options
|
||||
* @returns {Promise<import('../providers/iTunes').iTunesPodcastSearchResult[]>}
|
||||
*/
|
||||
async search(term, options = {}) {
|
||||
@ -20,12 +20,16 @@ class PodcastFinder {
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} term
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async findCovers(term) {
|
||||
if (!term) return null
|
||||
Logger.debug(`[iTunes] Searching for podcast covers with term "${term}"`)
|
||||
var results = await this.iTunesApi.searchPodcasts(term)
|
||||
const results = await this.iTunesApi.searchPodcasts(term)
|
||||
if (!results) return []
|
||||
return results.map(r => r.cover).filter(r => r)
|
||||
return results.map((r) => r.cover).filter((r) => r)
|
||||
}
|
||||
}
|
||||
module.exports = new PodcastFinder()
|
||||
module.exports = new PodcastFinder()
|
||||
|
||||
@ -224,6 +224,9 @@ class CoverSearchManager {
|
||||
if (!Array.isArray(results)) return covers
|
||||
|
||||
results.forEach((result) => {
|
||||
if (typeof result === 'string') {
|
||||
covers.push(result)
|
||||
}
|
||||
if (result.covers && Array.isArray(result.covers)) {
|
||||
covers.push(...result.covers)
|
||||
}
|
||||
|
||||
@ -283,6 +283,7 @@ class ApiRouter {
|
||||
this.router.get('/search/podcast', SearchController.findPodcasts.bind(this))
|
||||
this.router.get('/search/authors', SearchController.findAuthor.bind(this))
|
||||
this.router.get('/search/chapters', SearchController.findChapters.bind(this))
|
||||
this.router.get('/search/providers', SearchController.getAllProviders.bind(this))
|
||||
|
||||
//
|
||||
// Cache Routes (Admin and up)
|
||||
|
||||
@ -277,3 +277,57 @@ module.exports.timestampToSeconds = (timestamp) => {
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
class ValidationError extends Error {
|
||||
constructor(paramName, message, status = 400) {
|
||||
super(`Query parameter "${paramName}" ${message}`)
|
||||
this.name = 'ValidationError'
|
||||
this.paramName = paramName
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
module.exports.ValidationError = ValidationError
|
||||
|
||||
class NotFoundError extends Error {
|
||||
constructor(message, status = 404) {
|
||||
super(message)
|
||||
this.name = 'NotFoundError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
module.exports.NotFoundError = NotFoundError
|
||||
|
||||
/**
|
||||
* Safely extracts a query parameter as a string, rejecting arrays to prevent type confusion
|
||||
* Express query parameters can be arrays if the same parameter appears multiple times
|
||||
* @example ?author=Smith => "Smith"
|
||||
* @example ?author=Smith&author=Jones => throws error
|
||||
*
|
||||
* @param {Object} query - Query object
|
||||
* @param {string} paramName - Parameter name
|
||||
* @param {string} defaultValue - Default value if undefined/null
|
||||
* @param {boolean} required - Whether the parameter is required
|
||||
* @param {number} maxLength - Optional maximum length (defaults to 10000 to prevent ReDoS attacks)
|
||||
* @returns {string} String value
|
||||
* @throws {ValidationError} If value is an array
|
||||
* @throws {ValidationError} If value is too long
|
||||
* @throws {ValidationError} If value is required but not provided
|
||||
*/
|
||||
module.exports.getQueryParamAsString = (query, paramName, defaultValue = '', required = false, maxLength = 1000) => {
|
||||
const value = query[paramName]
|
||||
if (value === undefined || value === null) {
|
||||
if (required) {
|
||||
throw new ValidationError(paramName, 'is required')
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
// Explicitly reject arrays to prevent type confusion
|
||||
if (Array.isArray(value)) {
|
||||
throw new ValidationError(paramName, 'is an array')
|
||||
}
|
||||
// Reject excessively long strings to prevent ReDoS attacks
|
||||
if (typeof value === 'string' && value.length > maxLength) {
|
||||
throw new ValidationError(paramName, 'is too long')
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user