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.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v2
|
uses: github/codeql-action/init@v3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# 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).
|
# 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)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@v2
|
uses: github/codeql-action/autobuild@v3
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ 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
|
# 📚 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
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v2
|
uses: github/codeql-action/analyze@v3
|
||||||
with:
|
with:
|
||||||
category: '/language:${{matrix.language}}'
|
category: '/language:${{matrix.language}}'
|
||||||
|
|||||||
@ -88,7 +88,7 @@ export default {
|
|||||||
},
|
},
|
||||||
providers() {
|
providers() {
|
||||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||||
return this.$store.state.scanners.providers
|
return this.$store.state.scanners.bookProviders
|
||||||
},
|
},
|
||||||
libraryProvider() {
|
libraryProvider() {
|
||||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||||
@ -96,6 +96,9 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
init() {
|
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
|
// 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
|
// the selected provider to the current library default provider
|
||||||
if (!this.options.provider || this.lastUsedLibrary != this.currentLibraryId) {
|
if (!this.options.provider || this.lastUsedLibrary != this.currentLibraryId) {
|
||||||
@ -127,8 +130,7 @@ export default {
|
|||||||
this.show = false
|
this.show = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
mounted() {}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -133,8 +133,8 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
providers() {
|
providers() {
|
||||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
if (this.isPodcast) return this.$store.state.scanners.podcastCoverProviders
|
||||||
return [{ text: 'Best', value: 'best' }, ...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders, { text: 'All', value: 'all' }]
|
return this.$store.state.scanners.bookCoverProviders
|
||||||
},
|
},
|
||||||
searchTitleLabel() {
|
searchTitleLabel() {
|
||||||
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
||||||
@ -438,6 +438,8 @@ export default {
|
|||||||
mounted() {
|
mounted() {
|
||||||
// Setup socket listeners when component is mounted
|
// Setup socket listeners when component is mounted
|
||||||
this.addSocketListeners()
|
this.addSocketListeners()
|
||||||
|
// Fetch providers if not already loaded
|
||||||
|
this.$store.dispatch('scanners/fetchProviders')
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
// Cancel any ongoing search when component is destroyed
|
// 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">
|
<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">
|
<form @submit.prevent="submitSearch">
|
||||||
<div class="flex flex-wrap md:flex-nowrap items-center justify-start -mx-1">
|
<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 />
|
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
|
||||||
</div>
|
</div>
|
||||||
<div class="grow md:w-72 px-1">
|
<div class="grow md:w-72 px-1">
|
||||||
@ -253,6 +253,7 @@ export default {
|
|||||||
hasSearched: false,
|
hasSearched: false,
|
||||||
selectedMatch: null,
|
selectedMatch: null,
|
||||||
selectedMatchOrig: null,
|
selectedMatchOrig: null,
|
||||||
|
waitingForProviders: false,
|
||||||
selectedMatchUsage: {
|
selectedMatchUsage: {
|
||||||
title: true,
|
title: true,
|
||||||
subtitle: true,
|
subtitle: true,
|
||||||
@ -285,9 +286,19 @@ export default {
|
|||||||
handler(newVal) {
|
handler(newVal) {
|
||||||
if (newVal) this.init()
|
if (newVal) this.init()
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
providersLoaded(isLoaded) {
|
||||||
|
// Complete initialization once providers are loaded
|
||||||
|
if (isLoaded && this.waitingForProviders) {
|
||||||
|
this.waitingForProviders = false
|
||||||
|
this.initProviderAndSearch()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
providersLoaded() {
|
||||||
|
return this.$store.getters['scanners/areProvidersLoaded']
|
||||||
|
},
|
||||||
isProcessing: {
|
isProcessing: {
|
||||||
get() {
|
get() {
|
||||||
return this.processing
|
return this.processing
|
||||||
@ -319,7 +330,7 @@ export default {
|
|||||||
},
|
},
|
||||||
providers() {
|
providers() {
|
||||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||||
return this.$store.state.scanners.providers
|
return this.$store.state.scanners.bookProviders
|
||||||
},
|
},
|
||||||
searchTitleLabel() {
|
searchTitleLabel() {
|
||||||
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
||||||
@ -478,6 +489,24 @@ export default {
|
|||||||
|
|
||||||
this.checkboxToggled()
|
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() {
|
init() {
|
||||||
this.clearSelectedMatch()
|
this.clearSelectedMatch()
|
||||||
this.initSelectedMatchUsage()
|
this.initSelectedMatchUsage()
|
||||||
@ -495,19 +524,13 @@ export default {
|
|||||||
}
|
}
|
||||||
this.searchTitle = this.libraryItem.media.metadata.title
|
this.searchTitle = this.libraryItem.media.metadata.title
|
||||||
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
|
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
|
// Wait for providers to be loaded before setting provider and searching
|
||||||
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
|
if (this.providersLoaded || this.isPodcast) {
|
||||||
this.searchTitle = this.libraryItem.media.metadata.asin
|
this.waitingForProviders = false
|
||||||
this.searchAuthor = ''
|
this.initProviderAndSearch()
|
||||||
}
|
} else {
|
||||||
|
this.waitingForProviders = true
|
||||||
if (this.searchTitle) {
|
|
||||||
this.submitSearch()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
selectMatch(match) {
|
selectMatch(match) {
|
||||||
@ -637,6 +660,10 @@ export default {
|
|||||||
this.selectedMatch = null
|
this.selectedMatch = null
|
||||||
this.selectedMatchOrig = null
|
this.selectedMatchOrig = null
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
// Fetch providers if not already loaded
|
||||||
|
this.$store.dispatch('scanners/fetchProviders')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -74,7 +74,7 @@ export default {
|
|||||||
},
|
},
|
||||||
providers() {
|
providers() {
|
||||||
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
||||||
return this.$store.state.scanners.providers
|
return this.$store.state.scanners.bookProviders
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@ -156,6 +156,8 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.init()
|
this.init()
|
||||||
|
// Fetch providers if not already loaded
|
||||||
|
this.$store.dispatch('scanners/fetchProviders')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -104,7 +104,6 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
provider: null,
|
|
||||||
useSquareBookCovers: false,
|
useSquareBookCovers: false,
|
||||||
enableWatcher: false,
|
enableWatcher: false,
|
||||||
skipMatchingMediaWithAsin: false,
|
skipMatchingMediaWithAsin: false,
|
||||||
@ -134,10 +133,6 @@ export default {
|
|||||||
isPodcastLibrary() {
|
isPodcastLibrary() {
|
||||||
return this.mediaType === 'podcast'
|
return this.mediaType === 'podcast'
|
||||||
},
|
},
|
||||||
providers() {
|
|
||||||
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
|
||||||
return this.$store.state.scanners.providers
|
|
||||||
},
|
|
||||||
maskAsFinishedWhenItems() {
|
maskAsFinishedWhenItems() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -371,11 +371,13 @@ export default {
|
|||||||
},
|
},
|
||||||
customMetadataProviderAdded(provider) {
|
customMetadataProviderAdded(provider) {
|
||||||
if (!provider?.id) return
|
if (!provider?.id) return
|
||||||
this.$store.commit('scanners/addCustomMetadataProvider', provider)
|
// Refresh providers cache
|
||||||
|
this.$store.dispatch('scanners/refreshProviders')
|
||||||
},
|
},
|
||||||
customMetadataProviderRemoved(provider) {
|
customMetadataProviderRemoved(provider) {
|
||||||
if (!provider?.id) return
|
if (!provider?.id) return
|
||||||
this.$store.commit('scanners/removeCustomMetadataProvider', provider)
|
// Refresh providers cache
|
||||||
|
this.$store.dispatch('scanners/refreshProviders')
|
||||||
},
|
},
|
||||||
initializeSocket() {
|
initializeSocket() {
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
|
|||||||
@ -247,7 +247,8 @@ export default {
|
|||||||
return this.$store.state.serverSettings
|
return this.$store.state.serverSettings
|
||||||
},
|
},
|
||||||
providers() {
|
providers() {
|
||||||
return this.$store.state.scanners.providers
|
// Use book cover providers for the cover provider dropdown
|
||||||
|
return this.$store.state.scanners.bookCoverProviders || []
|
||||||
},
|
},
|
||||||
dateFormats() {
|
dateFormats() {
|
||||||
return this.$store.state.globals.dateFormats
|
return this.$store.state.globals.dateFormats
|
||||||
@ -416,6 +417,8 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.initServerSettings()
|
this.initServerSettings()
|
||||||
|
// Fetch providers if not already loaded (for cover provider dropdown)
|
||||||
|
this.$store.dispatch('scanners/fetchProviders')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -155,7 +155,7 @@ export default {
|
|||||||
},
|
},
|
||||||
providers() {
|
providers() {
|
||||||
if (this.selectedLibraryIsPodcast) return this.$store.state.scanners.podcastProviders
|
if (this.selectedLibraryIsPodcast) return this.$store.state.scanners.podcastProviders
|
||||||
return this.$store.state.scanners.providers
|
return this.$store.state.scanners.bookProviders
|
||||||
},
|
},
|
||||||
canFetchMetadata() {
|
canFetchMetadata() {
|
||||||
return !this.selectedLibraryIsPodcast && this.fetchMetadata.enabled
|
return !this.selectedLibraryIsPodcast && this.fetchMetadata.enabled
|
||||||
@ -394,6 +394,8 @@ export default {
|
|||||||
this.setMetadataProvider()
|
this.setMetadataProvider()
|
||||||
|
|
||||||
this.setDefaultFolder()
|
this.setDefaultFolder()
|
||||||
|
// Fetch providers if not already loaded
|
||||||
|
this.$store.dispatch('scanners/fetchProviders')
|
||||||
window.addEventListener('dragenter', this.dragenter)
|
window.addEventListener('dragenter', this.dragenter)
|
||||||
window.addEventListener('dragleave', this.dragleave)
|
window.addEventListener('dragleave', this.dragleave)
|
||||||
window.addEventListener('dragover', this.dragover)
|
window.addEventListener('dragover', this.dragover)
|
||||||
|
|||||||
@ -117,7 +117,6 @@ export const actions = {
|
|||||||
const library = data.library
|
const library = data.library
|
||||||
const filterData = data.filterdata
|
const filterData = data.filterdata
|
||||||
const issues = data.issues || 0
|
const issues = data.issues || 0
|
||||||
const customMetadataProviders = data.customMetadataProviders || []
|
|
||||||
const numUserPlaylists = data.numUserPlaylists
|
const numUserPlaylists = data.numUserPlaylists
|
||||||
|
|
||||||
dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true })
|
dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true })
|
||||||
@ -131,8 +130,6 @@ export const actions = {
|
|||||||
commit('setLibraryIssues', issues)
|
commit('setLibraryIssues', issues)
|
||||||
commit('setLibraryFilterData', filterData)
|
commit('setLibraryFilterData', filterData)
|
||||||
commit('setNumUserPlaylists', numUserPlaylists)
|
commit('setNumUserPlaylists', numUserPlaylists)
|
||||||
commit('scanners/setCustomMetadataProviders', customMetadataProviders, { root: true })
|
|
||||||
|
|
||||||
commit('setCurrentLibrary', { id: libraryId })
|
commit('setCurrentLibrary', { id: libraryId })
|
||||||
return data
|
return data
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,126 +1,60 @@
|
|||||||
export const state = () => ({
|
export const state = () => ({
|
||||||
providers: [
|
bookProviders: [],
|
||||||
{
|
podcastProviders: [],
|
||||||
text: 'Google Books',
|
bookCoverProviders: [],
|
||||||
value: 'google'
|
podcastCoverProviders: [],
|
||||||
},
|
providersLoaded: false
|
||||||
{
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getters = {
|
export const getters = {
|
||||||
checkBookProviderExists: state => (providerValue) => {
|
checkBookProviderExists: (state) => (providerValue) => {
|
||||||
return state.providers.some(p => p.value === providerValue)
|
return state.bookProviders.some((p) => p.value === providerValue)
|
||||||
},
|
},
|
||||||
checkPodcastProviderExists: state => (providerValue) => {
|
checkPodcastProviderExists: (state) => (providerValue) => {
|
||||||
return state.podcastProviders.some(p => p.value === providerValue)
|
return state.podcastProviders.some((p) => p.value === providerValue)
|
||||||
}
|
},
|
||||||
|
areProvidersLoaded: (state) => state.providersLoaded
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {}
|
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 mutations = {
|
export const mutations = {
|
||||||
addCustomMetadataProvider(state, provider) {
|
setAllProviders(state, providers) {
|
||||||
if (provider.mediaType === 'book') {
|
state.bookProviders = providers.books || []
|
||||||
if (state.providers.some(p => p.value === provider.slug)) return
|
state.podcastProviders = providers.podcasts || []
|
||||||
state.providers.push({
|
state.bookCoverProviders = providers.booksCovers || []
|
||||||
text: provider.name,
|
state.podcastCoverProviders = providers.podcasts || [] // Use same as bookCovers since podcasts use iTunes only
|
||||||
value: provider.slug
|
state.providersLoaded = true
|
||||||
})
|
|
||||||
} 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -221,13 +221,11 @@ class LibraryController {
|
|||||||
const includeArray = (req.query.include || '').split(',')
|
const includeArray = (req.query.include || '').split(',')
|
||||||
if (includeArray.includes('filterdata')) {
|
if (includeArray.includes('filterdata')) {
|
||||||
const filterdata = await libraryFilters.getFilterData(req.library.mediaType, req.library.id)
|
const filterdata = await libraryFilters.getFilterData(req.library.mediaType, req.library.id)
|
||||||
const customMetadataProviders = await Database.customMetadataProviderModel.getForClientByMediaType(req.library.mediaType)
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
filterdata,
|
filterdata,
|
||||||
issues: filterdata.numIssues,
|
issues: filterdata.numIssues,
|
||||||
numUserPlaylists: await Database.playlistModel.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id),
|
numUserPlaylists: await Database.playlistModel.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id),
|
||||||
customMetadataProviders,
|
|
||||||
library: req.library.toOldJSON()
|
library: req.library.toOldJSON()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,29 @@ const BookFinder = require('../finders/BookFinder')
|
|||||||
const PodcastFinder = require('../finders/PodcastFinder')
|
const PodcastFinder = require('../finders/PodcastFinder')
|
||||||
const AuthorFinder = require('../finders/AuthorFinder')
|
const AuthorFinder = require('../finders/AuthorFinder')
|
||||||
const Database = require('../Database')
|
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
|
* @typedef RequestUserObject
|
||||||
@ -16,6 +38,44 @@ const { isValidASIN } = require('../utils')
|
|||||||
class SearchController {
|
class SearchController {
|
||||||
constructor() {}
|
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
|
* GET: /api/search/books
|
||||||
*
|
*
|
||||||
@ -23,19 +83,25 @@ class SearchController {
|
|||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async findBooks(req, res) {
|
async findBooks(req, res) {
|
||||||
const id = req.query.id
|
try {
|
||||||
const libraryItem = await Database.libraryItemModel.getExpandedById(id)
|
const query = req.query
|
||||||
const provider = req.query.provider || 'google'
|
const provider = getQueryParamAsString(query, 'provider', 'google')
|
||||||
const title = req.query.title || ''
|
const title = getQueryParamAsString(query, 'title', '')
|
||||||
const author = req.query.author || ''
|
const author = getQueryParamAsString(query, 'author', '')
|
||||||
|
const id = getQueryParamAsString(query, 'id', '', true)
|
||||||
|
|
||||||
if (typeof provider !== 'string' || typeof title !== 'string' || typeof author !== 'string') {
|
// Fetch library item
|
||||||
Logger.error(`[SearchController] findBooks: Invalid request query params`)
|
const libraryItem = await SearchController.fetchLibraryItem(id)
|
||||||
return res.status(400).send('Invalid request query params')
|
|
||||||
|
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
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async findCovers(req, res) {
|
async findCovers(req, res) {
|
||||||
const query = req.query
|
try {
|
||||||
const podcast = query.podcast == 1
|
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') {
|
let results = null
|
||||||
Logger.error(`[SearchController] findCovers: Invalid title sent in query`)
|
if (podcast) results = await PodcastFinder.findCovers(title)
|
||||||
return res.sendStatus(400)
|
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
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async findPodcasts(req, res) {
|
async findPodcasts(req, res) {
|
||||||
const term = req.query.term
|
try {
|
||||||
const country = req.query.country || 'us'
|
const query = req.query
|
||||||
if (!term) {
|
const term = getQueryParamAsString(query, 'term', '', true)
|
||||||
Logger.error('[SearchController] Invalid request query param "term" is required')
|
const country = getQueryParamAsString(query, 'country', 'us')
|
||||||
return res.status(400).send('Invalid request query param "term" is required')
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await PodcastFinder.search(term, {
|
const results = await PodcastFinder.search(term, { country })
|
||||||
country
|
res.json(results)
|
||||||
})
|
} catch (error) {
|
||||||
res.json(results)
|
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
|
* GET: /api/search/authors
|
||||||
|
* Note: This endpoint is not currently used in the web client.
|
||||||
*
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithUser} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async findAuthor(req, res) {
|
async findAuthor(req, res) {
|
||||||
const query = req.query.q
|
try {
|
||||||
if (!query || typeof query !== 'string') {
|
const query = getQueryParamAsString(req.query, 'q', '', true)
|
||||||
Logger.error(`[SearchController] findAuthor: Invalid query param`)
|
|
||||||
return res.status(400).send('Invalid query param')
|
|
||||||
}
|
|
||||||
|
|
||||||
const author = await AuthorFinder.findAuthorByName(query)
|
const author = await AuthorFinder.findAuthorByName(query)
|
||||||
res.json(author)
|
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
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async findChapters(req, res) {
|
async findChapters(req, res) {
|
||||||
const asin = req.query.asin
|
try {
|
||||||
if (!isValidASIN(asin.toUpperCase())) {
|
const query = req.query
|
||||||
return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' })
|
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()
|
module.exports = new SearchController()
|
||||||
|
|||||||
@ -385,6 +385,11 @@ class BookFinder {
|
|||||||
|
|
||||||
if (!title) return books
|
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())
|
const isTitleAsin = isValidASIN(title.toUpperCase())
|
||||||
|
|
||||||
let actualTitleQuery = title
|
let actualTitleQuery = title
|
||||||
@ -402,7 +407,8 @@ class BookFinder {
|
|||||||
let authorCandidates = new BookFinder.AuthorCandidates(cleanAuthor, this.audnexus)
|
let authorCandidates = new BookFinder.AuthorCandidates(cleanAuthor, this.audnexus)
|
||||||
|
|
||||||
// Remove underscores and parentheses with their contents, and replace with a separator
|
// 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
|
// Split title into hypen-separated parts
|
||||||
const titleParts = cleanTitle.split(/ - | -|- /)
|
const titleParts = cleanTitle.split(/ - | -|- /)
|
||||||
for (const titlePart of titleParts) authorCandidates.add(titlePart)
|
for (const titlePart of titleParts) authorCandidates.add(titlePart)
|
||||||
@ -668,7 +674,9 @@ function cleanTitleForCompares(title, keepSubtitle = false) {
|
|||||||
let stripped = keepSubtitle ? title : stripSubtitle(title)
|
let stripped = keepSubtitle ? title : stripSubtitle(title)
|
||||||
|
|
||||||
// Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game")
|
// 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")
|
// Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
|
||||||
cleaned = cleaned.replace(/'/g, '')
|
cleaned = cleaned.replace(/'/g, '')
|
||||||
|
|||||||
@ -20,12 +20,16 @@ class PodcastFinder {
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} term
|
||||||
|
* @returns {Promise<string[]>}
|
||||||
|
*/
|
||||||
async findCovers(term) {
|
async findCovers(term) {
|
||||||
if (!term) return null
|
if (!term) return null
|
||||||
Logger.debug(`[iTunes] Searching for podcast covers with term "${term}"`)
|
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 []
|
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
|
if (!Array.isArray(results)) return covers
|
||||||
|
|
||||||
results.forEach((result) => {
|
results.forEach((result) => {
|
||||||
|
if (typeof result === 'string') {
|
||||||
|
covers.push(result)
|
||||||
|
}
|
||||||
if (result.covers && Array.isArray(result.covers)) {
|
if (result.covers && Array.isArray(result.covers)) {
|
||||||
covers.push(...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/podcast', SearchController.findPodcasts.bind(this))
|
||||||
this.router.get('/search/authors', SearchController.findAuthor.bind(this))
|
this.router.get('/search/authors', SearchController.findAuthor.bind(this))
|
||||||
this.router.get('/search/chapters', SearchController.findChapters.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)
|
// Cache Routes (Admin and up)
|
||||||
|
|||||||
@ -277,3 +277,57 @@ module.exports.timestampToSeconds = (timestamp) => {
|
|||||||
}
|
}
|
||||||
return null
|
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