mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-04-23 23:49:43 +00:00
Merge d5a2ea9feb into 1d0b7e383a
This commit is contained in:
commit
53106ce268
17 changed files with 592 additions and 29 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -24,3 +24,6 @@ sw.*
|
||||||
.idea/*
|
.idea/*
|
||||||
tailwind.compiled.css
|
tailwind.compiled.css
|
||||||
tailwind.config.js
|
tailwind.config.js
|
||||||
|
dev.sh
|
||||||
|
docs-backend-guide.md
|
||||||
|
docs-enhancements.md
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,16 @@
|
||||||
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" :label="$strings.LabelSequence" />
|
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" :label="$strings.LabelSequence" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex mt-2">
|
||||||
|
<div class="grow p-1">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ui-asin-input ref="asinInput" v-model="seriesAsin" :label="$strings.LabelSeriesAsin" :extracted-message="$strings.MessageAsinExtractedFromUrl" :valid-message="$strings.MessageValidAsinFormat" :invalid-message="$strings.MessageInvalidAsin" class="flex-grow" />
|
||||||
|
<ui-tooltip :text="$strings.MessageAsinCheck" direction="top" class="ml-2 mt-5">
|
||||||
|
<span class="material-symbols text-gray-400 hover:text-white cursor-help" style="font-size: 1.1rem">help</span>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-if="error" class="text-error text-sm mt-2 p-1">{{ error }}</div>
|
<div v-if="error" class="text-error text-sm mt-2 p-1">{{ error }}</div>
|
||||||
<div class="flex justify-end mt-2 p-1">
|
<div class="flex justify-end mt-2 p-1">
|
||||||
<ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
<ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||||
|
|
@ -45,7 +55,8 @@ export default {
|
||||||
return {
|
return {
|
||||||
el: null,
|
el: null,
|
||||||
content: null,
|
content: null,
|
||||||
error: null
|
error: null,
|
||||||
|
seriesAsin: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|
@ -55,6 +66,21 @@ export default {
|
||||||
} else {
|
} else {
|
||||||
this.setHide()
|
this.setHide()
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
selectedSeries: {
|
||||||
|
handler(newVal) {
|
||||||
|
if (!this.show) return
|
||||||
|
this.seriesAsin = newVal?.audibleSeriesAsin || ''
|
||||||
|
},
|
||||||
|
deep: true
|
||||||
|
},
|
||||||
|
// Watch for series name changes to auto-populate ASIN when selecting existing series
|
||||||
|
'selectedSeries.name': {
|
||||||
|
async handler(newName) {
|
||||||
|
if (!this.show || !newName || !this.isNewSeries) return
|
||||||
|
// Check if this matches an existing series in the library
|
||||||
|
await this.fetchSeriesAsinByName(newName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -77,6 +103,22 @@ export default {
|
||||||
this.$refs.sequenceInput.setFocus()
|
this.$refs.sequenceInput.setFocus()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async fetchSeriesAsinByName(seriesName) {
|
||||||
|
try {
|
||||||
|
const libraryId = this.$store.state.libraries.currentLibraryId
|
||||||
|
const series = this.$store.state.libraries.filterData?.series || []
|
||||||
|
const matchingSeries = series.find((se) => se.name.toLowerCase() === seriesName.toLowerCase())
|
||||||
|
if (!matchingSeries) return
|
||||||
|
|
||||||
|
// Fetch full series data to get ASIN
|
||||||
|
const fullSeries = await this.$axios.$get(`/api/libraries/${libraryId}/series/${matchingSeries.id}`)
|
||||||
|
if (fullSeries?.audibleSeriesAsin) {
|
||||||
|
this.seriesAsin = fullSeries.audibleSeriesAsin
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch series ASIN:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
setInputFocus() {
|
setInputFocus() {
|
||||||
if (this.isNewSeries) {
|
if (this.isNewSeries) {
|
||||||
// Focus on series input if new series
|
// Focus on series input if new series
|
||||||
|
|
@ -102,7 +144,18 @@ export default {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$emit('submit')
|
// Validate ASIN format if provided
|
||||||
|
if (this.seriesAsin && this.seriesAsin.trim()) {
|
||||||
|
const asin = this.seriesAsin.trim().toUpperCase()
|
||||||
|
if (!/^[A-Z0-9]{10}$/.test(asin)) {
|
||||||
|
this.error = this.$strings.MessageInvalidAsin
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.seriesAsin = asin
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass ASIN along with submit
|
||||||
|
this.$emit('submit', { audibleSeriesAsin: this.seriesAsin || null })
|
||||||
},
|
},
|
||||||
clickClose() {
|
clickClose() {
|
||||||
this.show = false
|
this.show = false
|
||||||
|
|
@ -114,6 +167,9 @@ export default {
|
||||||
},
|
},
|
||||||
setShow() {
|
setShow() {
|
||||||
this.error = null
|
this.error = null
|
||||||
|
// Load existing ASIN from the series if it exists
|
||||||
|
this.seriesAsin = this.selectedSeries?.audibleSeriesAsin || ''
|
||||||
|
|
||||||
if (!this.el || !this.content) {
|
if (!this.el || !this.content) {
|
||||||
this.init()
|
this.init()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -544,7 +544,8 @@ export default {
|
||||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||||
displayName: se.sequence ? `${se.series} #${se.sequence}` : se.series,
|
displayName: se.sequence ? `${se.series} #${se.sequence}` : se.series,
|
||||||
name: se.series,
|
name: se.series,
|
||||||
sequence: se.sequence || ''
|
sequence: se.sequence || '',
|
||||||
|
asin: se.asin || null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -580,7 +581,9 @@ export default {
|
||||||
seriesPayload.push({
|
seriesPayload.push({
|
||||||
id: seriesItem.id,
|
id: seriesItem.id,
|
||||||
name: seriesItem.name,
|
name: seriesItem.name,
|
||||||
sequence: seriesItem.sequence
|
sequence: seriesItem.sequence,
|
||||||
|
// Support both 'asin' (from provider) and 'audibleSeriesAsin' (from edit form)
|
||||||
|
asin: seriesItem.asin || seriesItem.audibleSeriesAsin || null
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
updatePayload.metadata.series = seriesPayload
|
updatePayload.metadata.series = seriesPayload
|
||||||
|
|
|
||||||
155
client/components/ui/AsinInput.vue
Normal file
155
client/components/ui/AsinInput.vue
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<label v-if="label" class="px-1 text-sm font-semibold">{{ label }}</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input ref="input" :value="value" type="text" :placeholder="placeholder" dir="auto" class="rounded-sm bg-primary text-gray-200 focus:bg-bg focus:outline-hidden border h-full w-full px-3 py-2 focus:border-gray-300 border-gray-600" @input="onInput" @paste="onPaste" @blur="onBlur" />
|
||||||
|
</div>
|
||||||
|
<p v-if="extracted" class="text-success text-xs mt-1 px-1">
|
||||||
|
<span class="material-symbols text-xs align-middle" style="font-size: 0.875rem">check_circle</span>
|
||||||
|
{{ extractedMessage }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="value && isValid" class="text-green-500 text-xs mt-1 px-1">
|
||||||
|
<span class="material-symbols text-xs align-middle" style="font-size: 0.875rem">check</span>
|
||||||
|
{{ validMessage }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="value && !isValid" class="text-red-500 text-xs mt-1 px-1">
|
||||||
|
<span class="material-symbols text-xs align-middle" style="font-size: 0.875rem">error</span>
|
||||||
|
{{ invalidMessage }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Specialized input component for Audible ASIN fields.
|
||||||
|
* - Validates 10 alphanumeric characters
|
||||||
|
* - Extracts ASIN from pasted Audible URLs
|
||||||
|
* - Shows validation feedback
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: 'B08G9PRS1K or paste Audible URL'
|
||||||
|
},
|
||||||
|
extractedMessage: {
|
||||||
|
type: String,
|
||||||
|
default: 'ASIN extracted from URL'
|
||||||
|
},
|
||||||
|
validMessage: {
|
||||||
|
type: String,
|
||||||
|
default: 'Valid ASIN format'
|
||||||
|
},
|
||||||
|
invalidMessage: {
|
||||||
|
type: String,
|
||||||
|
default: 'Invalid ASIN (must be exactly 10 alphanumeric characters)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
extracted: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isValid() {
|
||||||
|
if (!this.value) return false
|
||||||
|
return /^[A-Z0-9]{10}$/i.test(this.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
value(newVal, oldVal) {
|
||||||
|
// Reset extracted flag when value changes externally
|
||||||
|
if (newVal !== oldVal) {
|
||||||
|
this.extracted = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* Extract ASIN from Audible URL or return null
|
||||||
|
*/
|
||||||
|
extractAsinFromUrl(input) {
|
||||||
|
if (!input) return null
|
||||||
|
|
||||||
|
// If already looks like ASIN, return as-is (uppercase)
|
||||||
|
if (/^[A-Z0-9]{10}$/i.test(input)) {
|
||||||
|
return input.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract from URL - handles:
|
||||||
|
// /series/B08WJ59784 (ASIN directly after /series/)
|
||||||
|
// /series/Series-Name/B08WJ59784 (ASIN after series name)
|
||||||
|
const urlMatch = input.match(/\/series\/(?:[^/]+\/)?([A-Z0-9]{10})(?:[/?#]|$)/i)
|
||||||
|
if (urlMatch) {
|
||||||
|
return urlMatch[1].toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: look for B0-style ASIN anywhere (common for Audible)
|
||||||
|
const b0Match = input.match(/\b(B0[A-Z0-9]{8})\b/i)
|
||||||
|
if (b0Match) {
|
||||||
|
return b0Match[1].toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
onInput(e) {
|
||||||
|
const val = (e?.target?.value ?? '').trim()
|
||||||
|
this.extracted = false
|
||||||
|
this.$emit('input', val)
|
||||||
|
},
|
||||||
|
|
||||||
|
onPaste(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
const pasted = (e.clipboardData?.getData('text') ?? '').trim()
|
||||||
|
if (!pasted) return
|
||||||
|
|
||||||
|
const extractedAsin = this.extractAsinFromUrl(pasted)
|
||||||
|
const finalVal = extractedAsin || pasted
|
||||||
|
|
||||||
|
this.extracted = !!(extractedAsin && extractedAsin !== pasted)
|
||||||
|
this.$emit('input', finalVal)
|
||||||
|
|
||||||
|
// Sync the input element
|
||||||
|
if (e.target) {
|
||||||
|
e.target.value = finalVal
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onBlur(e) {
|
||||||
|
const val = (e?.target?.value ?? '').trim()
|
||||||
|
if (!val) {
|
||||||
|
this.extracted = false
|
||||||
|
this.$emit('input', '')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractedAsin = this.extractAsinFromUrl(val)
|
||||||
|
if (extractedAsin && extractedAsin !== val) {
|
||||||
|
this.extracted = true
|
||||||
|
this.$emit('input', extractedAsin)
|
||||||
|
if (e.target) e.target.value = extractedAsin
|
||||||
|
} else {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
this.$emit('blur')
|
||||||
|
},
|
||||||
|
|
||||||
|
setFocus() {
|
||||||
|
if (this.$refs.input) this.$refs.input.focus()
|
||||||
|
},
|
||||||
|
|
||||||
|
blur() {
|
||||||
|
if (this.$refs.input) this.$refs.input.blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -56,12 +56,35 @@ export default {
|
||||||
var _series = this.seriesItems.find((se) => se.id === series.id)
|
var _series = this.seriesItems.find((se) => se.id === series.id)
|
||||||
if (!_series) return
|
if (!_series) return
|
||||||
|
|
||||||
this.selectedSeries = {
|
// If this is an existing series (not new), fetch the full series data to get ASIN
|
||||||
..._series
|
if (!_series.id.startsWith('new-')) {
|
||||||
|
this.fetchSeriesData(_series.id).then((fullSeries) => {
|
||||||
|
this.selectedSeries = {
|
||||||
|
..._series,
|
||||||
|
audibleSeriesAsin: fullSeries?.audibleSeriesAsin || ''
|
||||||
|
}
|
||||||
|
this.originalSeriesSequence = _series.sequence
|
||||||
|
this.showSeriesForm = true
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.selectedSeries = {
|
||||||
|
..._series,
|
||||||
|
// Map 'asin' from match data to 'audibleSeriesAsin' for the edit form
|
||||||
|
audibleSeriesAsin: _series.asin || _series.audibleSeriesAsin || ''
|
||||||
|
}
|
||||||
|
this.originalSeriesSequence = _series.sequence
|
||||||
|
this.showSeriesForm = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async fetchSeriesData(seriesId) {
|
||||||
|
try {
|
||||||
|
const libraryId = this.$store.state.libraries.currentLibraryId
|
||||||
|
const series = await this.$axios.$get(`/api/libraries/${libraryId}/series/${seriesId}`)
|
||||||
|
return series
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch series data:', error)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
this.originalSeriesSequence = _series.sequence
|
|
||||||
this.showSeriesForm = true
|
|
||||||
},
|
},
|
||||||
addNewSeries() {
|
addNewSeries() {
|
||||||
this.selectedSeries = {
|
this.selectedSeries = {
|
||||||
|
|
@ -73,7 +96,7 @@ export default {
|
||||||
this.originalSeriesSequence = null
|
this.originalSeriesSequence = null
|
||||||
this.showSeriesForm = true
|
this.showSeriesForm = true
|
||||||
},
|
},
|
||||||
submitSeriesForm() {
|
submitSeriesForm(formData) {
|
||||||
if (!this.selectedSeries.name) {
|
if (!this.selectedSeries.name) {
|
||||||
this.$toast.error('Must enter a series')
|
this.$toast.error('Must enter a series')
|
||||||
return
|
return
|
||||||
|
|
@ -96,6 +119,11 @@ export default {
|
||||||
var selectedSeriesCopy = { ...this.selectedSeries }
|
var selectedSeriesCopy = { ...this.selectedSeries }
|
||||||
selectedSeriesCopy.displayName = selectedSeriesCopy.sequence ? `${selectedSeriesCopy.name} #${selectedSeriesCopy.sequence}` : selectedSeriesCopy.name
|
selectedSeriesCopy.displayName = selectedSeriesCopy.sequence ? `${selectedSeriesCopy.name} #${selectedSeriesCopy.sequence}` : selectedSeriesCopy.name
|
||||||
|
|
||||||
|
// Store ASIN for later update (after book is saved and series exists)
|
||||||
|
if (formData?.audibleSeriesAsin !== undefined) {
|
||||||
|
selectedSeriesCopy.audibleSeriesAsin = formData.audibleSeriesAsin
|
||||||
|
}
|
||||||
|
|
||||||
var seriesCopy = this.seriesItems.map((v) => ({ ...v }))
|
var seriesCopy = this.seriesItems.map((v) => ({ ...v }))
|
||||||
if (existingSeriesIndex >= 0) {
|
if (existingSeriesIndex >= 0) {
|
||||||
seriesCopy.splice(existingSeriesIndex, 1, selectedSeriesCopy)
|
seriesCopy.splice(existingSeriesIndex, 1, selectedSeriesCopy)
|
||||||
|
|
@ -105,7 +133,28 @@ export default {
|
||||||
this.seriesItems = seriesCopy
|
this.seriesItems = seriesCopy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If this is an existing series (not new), update the ASIN immediately
|
||||||
|
if (!this.selectedSeries.id.startsWith('new-') && formData?.audibleSeriesAsin !== undefined) {
|
||||||
|
this.updateSeriesAsin(this.selectedSeries.id, formData.audibleSeriesAsin)
|
||||||
|
}
|
||||||
|
|
||||||
this.showSeriesForm = false
|
this.showSeriesForm = false
|
||||||
|
},
|
||||||
|
async updateSeriesAsin(seriesId, asin) {
|
||||||
|
// Skip API call if ASIN is empty - backend safeguard prevents clearing anyway,
|
||||||
|
// but this avoids unnecessary network requests
|
||||||
|
if (!asin) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.$axios.$patch(`/api/series/${seriesId}`, {
|
||||||
|
audibleSeriesAsin: asin
|
||||||
|
})
|
||||||
|
this.$toast.success(this.$strings.ToastSeriesUpdateSuccess)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update series ASIN:', error)
|
||||||
|
this.$toast.error(this.$strings.ToastSeriesUpdateFailed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -576,6 +576,7 @@
|
||||||
"LabelSequence": "Sequence",
|
"LabelSequence": "Sequence",
|
||||||
"LabelSerial": "Serial",
|
"LabelSerial": "Serial",
|
||||||
"LabelSeries": "Series",
|
"LabelSeries": "Series",
|
||||||
|
"LabelSeriesAsin": "Audible Series ASIN",
|
||||||
"LabelSeriesName": "Series Name",
|
"LabelSeriesName": "Series Name",
|
||||||
"LabelSeriesProgress": "Series Progress",
|
"LabelSeriesProgress": "Series Progress",
|
||||||
"LabelServerLogLevel": "Server Log Level",
|
"LabelServerLogLevel": "Server Log Level",
|
||||||
|
|
@ -737,6 +738,7 @@
|
||||||
"MessageAddToPlayerQueue": "Add to player queue",
|
"MessageAddToPlayerQueue": "Add to player queue",
|
||||||
"MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.",
|
"MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.",
|
||||||
"MessageAsinCheck": "Ensure you are using the ASIN from the correct Audible region, not Amazon.",
|
"MessageAsinCheck": "Ensure you are using the ASIN from the correct Audible region, not Amazon.",
|
||||||
|
"MessageAsinExtractedFromUrl": "ASIN extracted from URL",
|
||||||
"MessageAuthenticationLegacyTokenWarning": "Legacy API tokens will be removed in the future. Use <a href=\"/config/api-keys\">API Keys</a> instead.",
|
"MessageAuthenticationLegacyTokenWarning": "Legacy API tokens will be removed in the future. Use <a href=\"/config/api-keys\">API Keys</a> instead.",
|
||||||
"MessageAuthenticationOIDCChangesRestart": "Restart your server after saving to apply OIDC changes.",
|
"MessageAuthenticationOIDCChangesRestart": "Restart your server after saving to apply OIDC changes.",
|
||||||
"MessageAuthenticationSecurityMessage": "Authentication has been improved for security. All users are required to re-login.",
|
"MessageAuthenticationSecurityMessage": "Authentication has been improved for security. All users are required to re-login.",
|
||||||
|
|
@ -938,6 +940,7 @@
|
||||||
"MessageUploaderItemFailed": "Failed to upload",
|
"MessageUploaderItemFailed": "Failed to upload",
|
||||||
"MessageUploaderItemSuccess": "Successfully Uploaded!",
|
"MessageUploaderItemSuccess": "Successfully Uploaded!",
|
||||||
"MessageUploading": "Uploading...",
|
"MessageUploading": "Uploading...",
|
||||||
|
"MessageValidAsinFormat": "Valid ASIN format",
|
||||||
"MessageValidCronExpression": "Valid cron expression",
|
"MessageValidCronExpression": "Valid cron expression",
|
||||||
"MessageWatcherIsDisabledGlobally": "Watcher is disabled globally in server settings",
|
"MessageWatcherIsDisabledGlobally": "Watcher is disabled globally in server settings",
|
||||||
"MessageXLibraryIsEmpty": "{0} Library is empty!",
|
"MessageXLibraryIsEmpty": "{0} Library is empty!",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,12 @@ components:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
example: The Sword of Truth is a series of twenty one epic fantasy novels written by Terry Goodkind.
|
example: The Sword of Truth is a series of twenty one epic fantasy novels written by Terry Goodkind.
|
||||||
|
audibleSeriesAsin:
|
||||||
|
description: The Audible ASIN (Amazon Standard Identification Number) for this series. Used for metadata lookups. Will be null if not set.
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
pattern: '^[A-Z0-9]{10}$'
|
||||||
|
example: B08G9PRS1K
|
||||||
sequence:
|
sequence:
|
||||||
description: The position in the series the book is.
|
description: The position in the series the book is.
|
||||||
type: string
|
type: string
|
||||||
|
|
@ -45,6 +51,8 @@ components:
|
||||||
$ref: '#/components/schemas/seriesName'
|
$ref: '#/components/schemas/seriesName'
|
||||||
description:
|
description:
|
||||||
$ref: '#/components/schemas/seriesDescription'
|
$ref: '#/components/schemas/seriesDescription'
|
||||||
|
audibleSeriesAsin:
|
||||||
|
$ref: '#/components/schemas/audibleSeriesAsin'
|
||||||
addedAt:
|
addedAt:
|
||||||
$ref: '../../schemas.yaml#/components/schemas/addedAt'
|
$ref: '../../schemas.yaml#/components/schemas/addedAt'
|
||||||
updatedAt:
|
updatedAt:
|
||||||
|
|
@ -73,6 +81,10 @@ components:
|
||||||
$ref: '#/components/schemas/seriesId'
|
$ref: '#/components/schemas/seriesId'
|
||||||
name:
|
name:
|
||||||
$ref: '#/components/schemas/seriesName'
|
$ref: '#/components/schemas/seriesName'
|
||||||
|
description:
|
||||||
|
$ref: '#/components/schemas/seriesDescription'
|
||||||
|
audibleSeriesAsin:
|
||||||
|
$ref: '#/components/schemas/audibleSeriesAsin'
|
||||||
addedAt:
|
addedAt:
|
||||||
$ref: '../../schemas.yaml#/components/schemas/addedAt'
|
$ref: '../../schemas.yaml#/components/schemas/addedAt'
|
||||||
nameIgnorePrefix:
|
nameIgnorePrefix:
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.32.1",
|
"version": "2.33.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.32.1",
|
"version": "2.33.0",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.32.1",
|
"version": "2.33.0",
|
||||||
"buildNumber": 1,
|
"buildNumber": 1,
|
||||||
"description": "Self-hosted audiobook and podcast server",
|
"description": "Self-hosted audiobook and podcast server",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|
|
||||||
|
|
@ -62,17 +62,37 @@ class SeriesController {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO: Currently unused in the client, should check for duplicate name
|
* PATCH /api/series/:id
|
||||||
|
* Update series metadata (name, description, audibleSeriesAsin)
|
||||||
|
*
|
||||||
|
* TODO: should check for duplicate name
|
||||||
*
|
*
|
||||||
* @param {SeriesControllerRequest} req
|
* @param {SeriesControllerRequest} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async update(req, res) {
|
async update(req, res) {
|
||||||
const keysToUpdate = ['name', 'description']
|
const keysToUpdate = ['name', 'description', 'audibleSeriesAsin']
|
||||||
const payload = {}
|
const payload = {}
|
||||||
for (const key of keysToUpdate) {
|
for (const key of keysToUpdate) {
|
||||||
if (req.body[key] !== undefined && typeof req.body[key] === 'string') {
|
if (req.body[key] !== undefined) {
|
||||||
payload[key] = req.body[key]
|
const value = req.body[key]
|
||||||
|
|
||||||
|
// audibleSeriesAsin accepts null, empty string, or string
|
||||||
|
// Model hook will normalize (extract from URL, uppercase) and validate
|
||||||
|
// SAFEGUARD: null/empty values will NOT clear an existing ASIN (prevents accidental data loss)
|
||||||
|
if (key === 'audibleSeriesAsin') {
|
||||||
|
if (value === null || value === '') {
|
||||||
|
// Skip adding to payload if empty - existing ASIN will be preserved
|
||||||
|
// To explicitly clear, user must delete/recreate series or use a special endpoint
|
||||||
|
continue
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
payload[key] = value // Model hook will normalize & validate
|
||||||
|
} else {
|
||||||
|
return res.status(400).send('audibleSeriesAsin must be a string or null')
|
||||||
|
}
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
payload[key] = value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!Object.keys(payload).length) {
|
if (!Object.keys(payload).length) {
|
||||||
|
|
@ -80,7 +100,15 @@ class SeriesController {
|
||||||
}
|
}
|
||||||
req.series.set(payload)
|
req.series.set(payload)
|
||||||
if (req.series.changed()) {
|
if (req.series.changed()) {
|
||||||
await req.series.save()
|
try {
|
||||||
|
await req.series.save()
|
||||||
|
} catch (error) {
|
||||||
|
// Handle model-level validation errors (e.g., invalid ASIN format)
|
||||||
|
if (error.message?.includes('ASIN') || error.message?.includes('audibleSeriesAsin')) {
|
||||||
|
return res.status(400).send(error.message)
|
||||||
|
}
|
||||||
|
throw error // Re-throw unexpected errors
|
||||||
|
}
|
||||||
SocketAuthority.emitter('series_updated', req.series.toOldJSON())
|
SocketAuthority.emitter('series_updated', req.series.toOldJSON())
|
||||||
}
|
}
|
||||||
res.json(req.series.toOldJSON())
|
res.json(req.series.toOldJSON())
|
||||||
|
|
|
||||||
107
server/migrations/v2.33.0-series-audible-asin.js
Normal file
107
server/migrations/v2.33.0-series-audible-asin.js
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
/**
|
||||||
|
* @typedef MigrationContext
|
||||||
|
* @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object.
|
||||||
|
* @property {import('../Logger')} logger - a Logger object.
|
||||||
|
*
|
||||||
|
* @typedef MigrationOptions
|
||||||
|
* @property {MigrationContext} context - an object containing the migration context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const migrationVersion = '2.33.0'
|
||||||
|
const migrationName = `${migrationVersion}-series-audible-asin`
|
||||||
|
const loggerPrefix = `[${migrationVersion} migration]`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This migration adds the audibleSeriesAsin column to the Series table.
|
||||||
|
*
|
||||||
|
* @param {MigrationOptions} options - an object containing the migration context.
|
||||||
|
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||||
|
*/
|
||||||
|
async function up({ context: { queryInterface, logger } }) {
|
||||||
|
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||||
|
|
||||||
|
// Check if Series table exists
|
||||||
|
let tableDescription
|
||||||
|
try {
|
||||||
|
tableDescription = await queryInterface.describeTable('Series')
|
||||||
|
} catch (error) {
|
||||||
|
logger.info(`${loggerPrefix} Series table does not exist. Migration not needed.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add audibleSeriesAsin column if it doesn't exist
|
||||||
|
if (!tableDescription.audibleSeriesAsin) {
|
||||||
|
logger.info(`${loggerPrefix} Adding audibleSeriesAsin column to Series table`)
|
||||||
|
await queryInterface.addColumn('Series', 'audibleSeriesAsin', {
|
||||||
|
type: 'STRING',
|
||||||
|
allowNull: true
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.info(`${loggerPrefix} audibleSeriesAsin column already exists`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add index for audibleSeriesAsin lookups (optional, for future metadata provider use)
|
||||||
|
const indexes = await queryInterface.showIndex('Series')
|
||||||
|
const indexExists = indexes.some((index) => index.name === 'series_audible_asin_index')
|
||||||
|
|
||||||
|
if (!indexExists) {
|
||||||
|
logger.info(`${loggerPrefix} Adding index on audibleSeriesAsin column`)
|
||||||
|
try {
|
||||||
|
await queryInterface.addIndex('Series', {
|
||||||
|
fields: ['audibleSeriesAsin'],
|
||||||
|
name: 'series_audible_asin_index'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`${loggerPrefix} Failed to add index: ${error.message}`)
|
||||||
|
// Non-fatal - column still added successfully
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info(`${loggerPrefix} Index on audibleSeriesAsin already exists`)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This migration removes the audibleSeriesAsin column from the Series table.
|
||||||
|
*
|
||||||
|
* @param {MigrationOptions} options - an object containing the migration context.
|
||||||
|
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||||
|
*/
|
||||||
|
async function down({ context: { queryInterface, logger } }) {
|
||||||
|
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||||
|
|
||||||
|
// Check if Series table exists
|
||||||
|
let tableDescription
|
||||||
|
try {
|
||||||
|
tableDescription = await queryInterface.describeTable('Series')
|
||||||
|
} catch (error) {
|
||||||
|
logger.info(`${loggerPrefix} Series table does not exist. Downgrade not needed.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove index first
|
||||||
|
const indexes = await queryInterface.showIndex('Series')
|
||||||
|
const indexExists = indexes.some((index) => index.name === 'series_audible_asin_index')
|
||||||
|
|
||||||
|
if (indexExists) {
|
||||||
|
logger.info(`${loggerPrefix} Removing index on audibleSeriesAsin column`)
|
||||||
|
try {
|
||||||
|
await queryInterface.removeIndex('Series', 'series_audible_asin_index')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`${loggerPrefix} Failed to remove index: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove column if it exists
|
||||||
|
if (tableDescription.audibleSeriesAsin) {
|
||||||
|
logger.info(`${loggerPrefix} Removing audibleSeriesAsin column from Series table`)
|
||||||
|
await queryInterface.removeColumn('Series', 'audibleSeriesAsin')
|
||||||
|
} else {
|
||||||
|
logger.info(`${loggerPrefix} audibleSeriesAsin column does not exist`)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { up, down }
|
||||||
|
|
@ -524,8 +524,16 @@ class Book extends Model {
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
Logger.debug(`[Book] "${this.title}" Updated series "${existingSeries.name}" sequence ${seriesObjSequence}`)
|
Logger.debug(`[Book] "${this.title}" Updated series "${existingSeries.name}" sequence ${seriesObjSequence}`)
|
||||||
}
|
}
|
||||||
|
// Update series ASIN if provided and not already set
|
||||||
|
if (seriesObj.asin && !existingSeries.audibleSeriesAsin) {
|
||||||
|
existingSeries.audibleSeriesAsin = seriesObj.asin
|
||||||
|
await existingSeries.save()
|
||||||
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
|
SocketAuthority.emitter('series_updated', existingSeries.toOldJSON())
|
||||||
|
Logger.debug(`[Book] "${this.title}" Updated series "${existingSeries.name}" ASIN ${seriesObj.asin}`)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const series = await seriesModel.findOrCreateByNameAndLibrary(seriesObj.name, libraryId)
|
const series = await seriesModel.findOrCreateByNameAndLibrary(seriesObj.name, libraryId, seriesObj.asin)
|
||||||
series.bookSeries = await bookSeriesModel.create({ bookId: this.id, seriesId: series.id, sequence: seriesObjSequence })
|
series.bookSeries = await bookSeriesModel.create({ bookId: this.id, seriesId: series.id, sequence: seriesObjSequence })
|
||||||
this.series.push(series)
|
this.series.push(series)
|
||||||
seriesAdded.push(series)
|
seriesAdded.push(series)
|
||||||
|
|
@ -553,7 +561,7 @@ class Book extends Model {
|
||||||
*/
|
*/
|
||||||
oldMetadataToJSON() {
|
oldMetadataToJSON() {
|
||||||
const authors = this.authors.map((au) => ({ id: au.id, name: au.name }))
|
const authors = this.authors.map((au) => ({ id: au.id, name: au.name }))
|
||||||
const series = this.series.map((se) => ({ id: se.id, name: se.name, sequence: se.bookSeries.sequence }))
|
const series = this.series.map((se) => ({ id: se.id, name: se.name, sequence: se.bookSeries.sequence, audibleSeriesAsin: se.audibleSeriesAsin }))
|
||||||
return {
|
return {
|
||||||
title: this.title,
|
title: this.title,
|
||||||
subtitle: this.subtitle,
|
subtitle: this.subtitle,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,37 @@ const { DataTypes, Model, where, fn, col, literal } = require('sequelize')
|
||||||
|
|
||||||
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils/index')
|
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils/index')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize and validate Audible Series ASIN.
|
||||||
|
* - null/undefined/empty → null
|
||||||
|
* - Extracts ASIN from Audible series URLs
|
||||||
|
* - Validates 10 alphanumeric chars
|
||||||
|
* - Uppercases
|
||||||
|
*
|
||||||
|
* @param {*} value
|
||||||
|
* @returns {string|null} Normalized ASIN or null
|
||||||
|
* @throws {Error} If value is invalid format
|
||||||
|
*/
|
||||||
|
function normalizeAudibleSeriesAsin(value) {
|
||||||
|
if (value == null) return null
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new Error('audibleSeriesAsin must be a string or null')
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = value.trim()
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
// Extract ASIN from Audible series URL if provided
|
||||||
|
// e.g., https://www.audible.com/series/Harry-Potter/B0182NWM9I or /series/B0182NWM9I
|
||||||
|
const urlMatch = raw.match(/\/series\/(?:[^/]+\/)?([A-Z0-9]{10})(?:[/?#]|$)/i)
|
||||||
|
const candidate = (urlMatch ? urlMatch[1] : raw).toUpperCase()
|
||||||
|
|
||||||
|
if (!/^[A-Z0-9]{10}$/.test(candidate)) {
|
||||||
|
throw new Error('Invalid ASIN format. Must be exactly 10 alphanumeric characters.')
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
class Series extends Model {
|
class Series extends Model {
|
||||||
constructor(values, options) {
|
constructor(values, options) {
|
||||||
super(values, options)
|
super(values, options)
|
||||||
|
|
@ -14,6 +45,8 @@ class Series extends Model {
|
||||||
this.nameIgnorePrefix
|
this.nameIgnorePrefix
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
this.description
|
this.description
|
||||||
|
/** @type {string} */
|
||||||
|
this.audibleSeriesAsin
|
||||||
/** @type {UUIDV4} */
|
/** @type {UUIDV4} */
|
||||||
this.libraryId
|
this.libraryId
|
||||||
/** @type {Date} */
|
/** @type {Date} */
|
||||||
|
|
@ -70,15 +103,26 @@ class Series extends Model {
|
||||||
*
|
*
|
||||||
* @param {string} seriesName
|
* @param {string} seriesName
|
||||||
* @param {string} libraryId
|
* @param {string} libraryId
|
||||||
|
* @param {string} [asin] - Optional Audible series ASIN
|
||||||
* @returns {Promise<Series>}
|
* @returns {Promise<Series>}
|
||||||
*/
|
*/
|
||||||
static async findOrCreateByNameAndLibrary(seriesName, libraryId) {
|
static async findOrCreateByNameAndLibrary(seriesName, libraryId, asin = null) {
|
||||||
const series = await this.getByNameAndLibrary(seriesName, libraryId)
|
const series = await this.getByNameAndLibrary(seriesName, libraryId)
|
||||||
if (series) return series
|
if (series) {
|
||||||
|
// Update ASIN if provided and not already set
|
||||||
|
if (asin && !series.audibleSeriesAsin) {
|
||||||
|
series.audibleSeriesAsin = asin
|
||||||
|
await series.save()
|
||||||
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
|
SocketAuthority.emitter('series_updated', series.toOldJSON())
|
||||||
|
}
|
||||||
|
return series
|
||||||
|
}
|
||||||
return this.create({
|
return this.create({
|
||||||
name: seriesName,
|
name: seriesName,
|
||||||
nameIgnorePrefix: getTitleIgnorePrefix(seriesName),
|
nameIgnorePrefix: getTitleIgnorePrefix(seriesName),
|
||||||
libraryId
|
libraryId,
|
||||||
|
audibleSeriesAsin: asin || null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,7 +140,8 @@ class Series extends Model {
|
||||||
},
|
},
|
||||||
name: DataTypes.STRING,
|
name: DataTypes.STRING,
|
||||||
nameIgnorePrefix: DataTypes.STRING,
|
nameIgnorePrefix: DataTypes.STRING,
|
||||||
description: DataTypes.TEXT
|
description: DataTypes.TEXT,
|
||||||
|
audibleSeriesAsin: DataTypes.STRING
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sequelize,
|
sequelize,
|
||||||
|
|
@ -129,6 +174,14 @@ class Series extends Model {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Hook to normalize/validate audibleSeriesAsin before save
|
||||||
|
// This ensures ALL routes get the same validation
|
||||||
|
Series.beforeValidate((series) => {
|
||||||
|
if (series.changed('audibleSeriesAsin')) {
|
||||||
|
series.audibleSeriesAsin = normalizeAudibleSeriesAsin(series.audibleSeriesAsin)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const { library } = sequelize.models
|
const { library } = sequelize.models
|
||||||
library.hasMany(Series, {
|
library.hasMany(Series, {
|
||||||
onDelete: 'CASCADE'
|
onDelete: 'CASCADE'
|
||||||
|
|
@ -171,6 +224,7 @@ class Series extends Model {
|
||||||
name: this.name,
|
name: this.name,
|
||||||
nameIgnorePrefix: getTitlePrefixAtEnd(this.name),
|
nameIgnorePrefix: getTitlePrefixAtEnd(this.name),
|
||||||
description: this.description,
|
description: this.description,
|
||||||
|
audibleSeriesAsin: this.audibleSeriesAsin,
|
||||||
addedAt: this.createdAt.valueOf(),
|
addedAt: this.createdAt.valueOf(),
|
||||||
updatedAt: this.updatedAt.valueOf(),
|
updatedAt: this.updatedAt.valueOf(),
|
||||||
libraryId: this.libraryId
|
libraryId: this.libraryId
|
||||||
|
|
@ -187,3 +241,4 @@ class Series extends Model {
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Series
|
module.exports = Series
|
||||||
|
module.exports.normalizeAudibleSeriesAsin = normalizeAudibleSeriesAsin
|
||||||
|
|
|
||||||
|
|
@ -47,13 +47,15 @@ class Audible {
|
||||||
if (seriesPrimary) {
|
if (seriesPrimary) {
|
||||||
series.push({
|
series.push({
|
||||||
series: seriesPrimary.name,
|
series: seriesPrimary.name,
|
||||||
sequence: this.cleanSeriesSequence(seriesPrimary.name, seriesPrimary.position || '')
|
sequence: this.cleanSeriesSequence(seriesPrimary.name, seriesPrimary.position || ''),
|
||||||
|
asin: seriesPrimary.asin || null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (seriesSecondary) {
|
if (seriesSecondary) {
|
||||||
series.push({
|
series.push({
|
||||||
series: seriesSecondary.name,
|
series: seriesSecondary.name,
|
||||||
sequence: this.cleanSeriesSequence(seriesSecondary.name, seriesSecondary.position || '')
|
sequence: this.cleanSeriesSequence(seriesSecondary.name, seriesSecondary.position || ''),
|
||||||
|
asin: seriesSecondary.asin || null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -303,17 +303,35 @@ class Scanner {
|
||||||
Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Updated series sequence for "${existingSeries.name}" to ${seriesMatchItem.sequence} in "${libraryItem.media.title}"`)
|
Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Updated series sequence for "${existingSeries.name}" to ${seriesMatchItem.sequence} in "${libraryItem.media.title}"`)
|
||||||
hasSeriesUpdates = true
|
hasSeriesUpdates = true
|
||||||
}
|
}
|
||||||
|
// Update series ASIN if provided and not already set
|
||||||
|
if (seriesMatchItem.asin && !existingSeries.audibleSeriesAsin) {
|
||||||
|
existingSeries.set({ audibleSeriesAsin: seriesMatchItem.asin })
|
||||||
|
if (existingSeries.changed()) {
|
||||||
|
await existingSeries.save()
|
||||||
|
SocketAuthority.emitter('series_updated', existingSeries.toOldJSON())
|
||||||
|
Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Updated series "${existingSeries.name}" with ASIN ${seriesMatchItem.asin}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let seriesItem = await Database.seriesModel.getByNameAndLibrary(seriesMatchItem.series, libraryItem.libraryId)
|
let seriesItem = await Database.seriesModel.getByNameAndLibrary(seriesMatchItem.series, libraryItem.libraryId)
|
||||||
if (!seriesItem) {
|
if (!seriesItem) {
|
||||||
seriesItem = await Database.seriesModel.create({
|
seriesItem = await Database.seriesModel.create({
|
||||||
name: seriesMatchItem.series,
|
name: seriesMatchItem.series,
|
||||||
nameIgnorePrefix: getTitleIgnorePrefix(seriesMatchItem.series),
|
nameIgnorePrefix: getTitleIgnorePrefix(seriesMatchItem.series),
|
||||||
libraryId: libraryItem.libraryId
|
libraryId: libraryItem.libraryId,
|
||||||
|
audibleSeriesAsin: seriesMatchItem.asin || null
|
||||||
})
|
})
|
||||||
// Update filter data
|
// Update filter data
|
||||||
Database.addSeriesToFilterData(libraryItem.libraryId, seriesItem.name, seriesItem.id)
|
Database.addSeriesToFilterData(libraryItem.libraryId, seriesItem.name, seriesItem.id)
|
||||||
SocketAuthority.emitter('series_added', seriesItem.toOldJSON())
|
SocketAuthority.emitter('series_added', seriesItem.toOldJSON())
|
||||||
|
} else if (seriesMatchItem.asin && !seriesItem.audibleSeriesAsin) {
|
||||||
|
// Series exists but has no ASIN, update it
|
||||||
|
seriesItem.set({ audibleSeriesAsin: seriesMatchItem.asin })
|
||||||
|
if (seriesItem.changed()) {
|
||||||
|
await seriesItem.save()
|
||||||
|
SocketAuthority.emitter('series_updated', seriesItem.toOldJSON())
|
||||||
|
Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Updated series "${seriesItem.name}" with ASIN ${seriesMatchItem.asin}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const bookSeries = await Database.bookSeriesModel.create({
|
const bookSeries = await Database.bookSeriesModel.create({
|
||||||
seriesId: seriesItem.id,
|
seriesId: seriesItem.id,
|
||||||
|
|
|
||||||
|
|
@ -1179,12 +1179,21 @@ module.exports = {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search series
|
// Search series by name or Audible ASIN
|
||||||
const matchName = textSearchQuery.matchExpression('name')
|
const matchName = textSearchQuery.matchExpression('name')
|
||||||
const allSeries = await Database.seriesModel.findAll({
|
const allSeries = await Database.seriesModel.findAll({
|
||||||
where: {
|
where: {
|
||||||
[Sequelize.Op.and]: [
|
[Sequelize.Op.and]: [
|
||||||
Sequelize.literal(matchName),
|
{
|
||||||
|
[Sequelize.Op.or]: [
|
||||||
|
Sequelize.literal(matchName),
|
||||||
|
{
|
||||||
|
audibleSeriesAsin: {
|
||||||
|
[Sequelize.Op.substring]: query
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
libraryId: library.id
|
libraryId: library.id
|
||||||
}
|
}
|
||||||
|
|
|
||||||
55
test/server/models/Series.test.js
Normal file
55
test/server/models/Series.test.js
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
const { expect } = require('chai')
|
||||||
|
const { normalizeAudibleSeriesAsin } = require('../../../server/models/Series')
|
||||||
|
|
||||||
|
describe('Series', function () {
|
||||||
|
describe('normalizeAudibleSeriesAsin', function () {
|
||||||
|
it('should return null for null/undefined/empty', function () {
|
||||||
|
expect(normalizeAudibleSeriesAsin(null)).to.equal(null)
|
||||||
|
expect(normalizeAudibleSeriesAsin(undefined)).to.equal(null)
|
||||||
|
expect(normalizeAudibleSeriesAsin('')).to.equal(null)
|
||||||
|
expect(normalizeAudibleSeriesAsin(' ')).to.equal(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should uppercase valid ASINs', function () {
|
||||||
|
expect(normalizeAudibleSeriesAsin('b0182nwm9i')).to.equal('B0182NWM9I')
|
||||||
|
expect(normalizeAudibleSeriesAsin('B0182NWM9I')).to.equal('B0182NWM9I')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should trim whitespace', function () {
|
||||||
|
expect(normalizeAudibleSeriesAsin(' B0182NWM9I ')).to.equal('B0182NWM9I')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract ASIN from Audible series URL', function () {
|
||||||
|
expect(normalizeAudibleSeriesAsin('https://www.audible.com/series/Harry-Potter/B0182NWM9I')).to.equal('B0182NWM9I')
|
||||||
|
expect(normalizeAudibleSeriesAsin('https://www.audible.com/series/B0182NWM9I')).to.equal('B0182NWM9I')
|
||||||
|
expect(normalizeAudibleSeriesAsin('/series/Harry-Potter/B0182NWM9I')).to.equal('B0182NWM9I')
|
||||||
|
expect(normalizeAudibleSeriesAsin('/series/B0182NWM9I')).to.equal('B0182NWM9I')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract ASIN from URL with query params', function () {
|
||||||
|
expect(normalizeAudibleSeriesAsin('https://www.audible.com/series/B0182NWM9I?ref=a_search')).to.equal('B0182NWM9I')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw for invalid ASIN format (too short)', function () {
|
||||||
|
expect(() => normalizeAudibleSeriesAsin('B0182NWM9')).to.throw('Invalid ASIN format')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw for invalid ASIN format (too long)', function () {
|
||||||
|
expect(() => normalizeAudibleSeriesAsin('B0182NWM9I1')).to.throw('Invalid ASIN format')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw for invalid characters', function () {
|
||||||
|
expect(() => normalizeAudibleSeriesAsin('B0182NWM9-')).to.throw('Invalid ASIN format')
|
||||||
|
expect(() => normalizeAudibleSeriesAsin('B0182NWM9!')).to.throw('Invalid ASIN format')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw for non-string types', function () {
|
||||||
|
expect(() => normalizeAudibleSeriesAsin(123)).to.throw('audibleSeriesAsin must be a string or null')
|
||||||
|
expect(() => normalizeAudibleSeriesAsin({})).to.throw('audibleSeriesAsin must be a string or null')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw for URL without valid ASIN', function () {
|
||||||
|
expect(() => normalizeAudibleSeriesAsin('https://www.audible.com/series/Harry-Potter')).to.throw('Invalid ASIN format')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue