feat: add UI for editing series Audible ASIN

- Create AsinInput.vue component for ASIN input with URL extraction
- Update EditSeriesInputInnerModal to include ASIN field
- Update SeriesInputWidget to fetch and save ASIN data
- Add SeriesController PATCH endpoint for updating ASIN
- Add localization strings for ASIN-related messages

The AsinInput component automatically extracts ASINs from pasted
Audible URLs and provides validation feedback.
This commit is contained in:
Quentin King 2026-01-03 10:34:05 -06:00
parent 40606eb1af
commit b8b3a20498
9 changed files with 322 additions and 16 deletions

3
.gitignore vendored
View file

@ -24,3 +24,6 @@ sw.*
.idea/*
tailwind.compiled.css
tailwind.config.js
dev.sh
docs-backend-guide.md
docs-enhancements.md

View file

@ -14,6 +14,16 @@
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" :label="$strings.LabelSequence" />
</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 class="flex justify-end mt-2 p-1">
<ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
@ -45,7 +55,8 @@ export default {
return {
el: null,
content: null,
error: null
error: null,
seriesAsin: ''
}
},
watch: {
@ -55,6 +66,21 @@ export default {
} else {
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: {
@ -77,6 +103,22 @@ export default {
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() {
if (this.isNewSeries) {
// Focus on series input if new series
@ -102,7 +144,18 @@ export default {
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() {
this.show = false
@ -114,6 +167,9 @@ export default {
},
setShow() {
this.error = null
// Load existing ASIN from the series if it exists
this.seriesAsin = this.selectedSeries?.audibleSeriesAsin || ''
if (!this.el || !this.content) {
this.init()
}

View 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>

View file

@ -56,12 +56,35 @@ export default {
var _series = this.seriesItems.find((se) => se.id === series.id)
if (!_series) return
this.selectedSeries = {
..._series
// If this is an existing series (not new), fetch the full series data to get ASIN
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() {
this.selectedSeries = {
@ -73,7 +96,7 @@ export default {
this.originalSeriesSequence = null
this.showSeriesForm = true
},
submitSeriesForm() {
submitSeriesForm(formData) {
if (!this.selectedSeries.name) {
this.$toast.error('Must enter a series')
return
@ -96,6 +119,11 @@ export default {
var selectedSeriesCopy = { ...this.selectedSeries }
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 }))
if (existingSeriesIndex >= 0) {
seriesCopy.splice(existingSeriesIndex, 1, selectedSeriesCopy)
@ -105,7 +133,28 @@ export default {
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
},
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)
}
}
}
}

View file

@ -576,6 +576,7 @@
"LabelSequence": "Sequence",
"LabelSerial": "Serial",
"LabelSeries": "Series",
"LabelSeriesAsin": "Audible Series ASIN",
"LabelSeriesName": "Series Name",
"LabelSeriesProgress": "Series Progress",
"LabelServerLogLevel": "Server Log Level",
@ -737,6 +738,7 @@
"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>.",
"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.",
"MessageAuthenticationOIDCChangesRestart": "Restart your server after saving to apply OIDC changes.",
"MessageAuthenticationSecurityMessage": "Authentication has been improved for security. All users are required to re-login.",
@ -938,6 +940,7 @@
"MessageUploaderItemFailed": "Failed to upload",
"MessageUploaderItemSuccess": "Successfully Uploaded!",
"MessageUploading": "Uploading...",
"MessageValidAsinFormat": "Valid ASIN format",
"MessageValidCronExpression": "Valid cron expression",
"MessageWatcherIsDisabledGlobally": "Watcher is disabled globally in server settings",
"MessageXLibraryIsEmpty": "{0} Library is empty!",

View file

@ -14,6 +14,12 @@ components:
type: string
nullable: true
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:
description: The position in the series the book is.
type: string
@ -45,6 +51,8 @@ components:
$ref: '#/components/schemas/seriesName'
description:
$ref: '#/components/schemas/seriesDescription'
audibleSeriesAsin:
$ref: '#/components/schemas/audibleSeriesAsin'
addedAt:
$ref: '../../schemas.yaml#/components/schemas/addedAt'
updatedAt:
@ -73,6 +81,10 @@ components:
$ref: '#/components/schemas/seriesId'
name:
$ref: '#/components/schemas/seriesName'
description:
$ref: '#/components/schemas/seriesDescription'
audibleSeriesAsin:
$ref: '#/components/schemas/audibleSeriesAsin'
addedAt:
$ref: '../../schemas.yaml#/components/schemas/addedAt'
nameIgnorePrefix:

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
"version": "2.32.1",
"version": "2.33.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.32.1",
"version": "2.33.0",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",

View file

@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.32.1",
"version": "2.33.0",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",

View file

@ -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 {Response} res
*/
async update(req, res) {
const keysToUpdate = ['name', 'description']
const keysToUpdate = ['name', 'description', 'audibleSeriesAsin']
const payload = {}
for (const key of keysToUpdate) {
if (req.body[key] !== undefined && typeof req.body[key] === 'string') {
payload[key] = req.body[key]
if (req.body[key] !== undefined) {
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) {
@ -80,7 +100,15 @@ class SeriesController {
}
req.series.set(payload)
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())
}
res.json(req.series.toOldJSON())