diff --git a/.gitignore b/.gitignore
index 12ebec1c2..6256bb889 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,6 @@ sw.*
.idea/*
tailwind.compiled.css
tailwind.config.js
+dev.sh
+docs-backend-guide.md
+docs-enhancements.md
diff --git a/client/components/modals/EditSeriesInputInnerModal.vue b/client/components/modals/EditSeriesInputInnerModal.vue
index bd568321f..8d749f5ef 100644
--- a/client/components/modals/EditSeriesInputInnerModal.vue
+++ b/client/components/modals/EditSeriesInputInnerModal.vue
@@ -14,6 +14,16 @@
+
{{ $strings.ButtonSubmit }}
@@ -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()
}
diff --git a/client/components/modals/item/tabs/Match.vue b/client/components/modals/item/tabs/Match.vue
index 4b92f6cd8..763a1bfd4 100644
--- a/client/components/modals/item/tabs/Match.vue
+++ b/client/components/modals/item/tabs/Match.vue
@@ -544,7 +544,8 @@ export default {
id: `new-${Math.floor(Math.random() * 10000)}`,
displayName: se.sequence ? `${se.series} #${se.sequence}` : se.series,
name: se.series,
- sequence: se.sequence || ''
+ sequence: se.sequence || '',
+ asin: se.asin || null
}
})
}
@@ -580,7 +581,9 @@ export default {
seriesPayload.push({
id: seriesItem.id,
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
diff --git a/client/components/ui/AsinInput.vue b/client/components/ui/AsinInput.vue
new file mode 100644
index 000000000..6041e1a25
--- /dev/null
+++ b/client/components/ui/AsinInput.vue
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+
+ check_circle
+ {{ extractedMessage }}
+
+
+ check
+ {{ validMessage }}
+
+
+ error
+ {{ invalidMessage }}
+
+
+
+
+
diff --git a/client/components/widgets/SeriesInputWidget.vue b/client/components/widgets/SeriesInputWidget.vue
index 3dab0605a..25c1e9b29 100644
--- a/client/components/widgets/SeriesInputWidget.vue
+++ b/client/components/widgets/SeriesInputWidget.vue
@@ -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)
+ }
}
}
}
diff --git a/client/strings/en-us.json b/client/strings/en-us.json
index fb2bcb281..de550bba6 100644
--- a/client/strings/en-us.json
+++ b/client/strings/en-us.json
@@ -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
Apprise API running or an api that will handle those same requests.
The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at
http://192.168.1.1:8337 then you would put
http://192.168.1.1:8337/notify.",
"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
API Keys 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!",
diff --git a/docs/objects/entities/Series.yaml b/docs/objects/entities/Series.yaml
index ef35a5b35..9f671dc90 100644
--- a/docs/objects/entities/Series.yaml
+++ b/docs/objects/entities/Series.yaml
@@ -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:
diff --git a/package-lock.json b/package-lock.json
index 08707893d..e07fba51d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 3ee3fb391..3108b5170 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/server/controllers/SeriesController.js b/server/controllers/SeriesController.js
index 21c93f332..08100596d 100644
--- a/server/controllers/SeriesController.js
+++ b/server/controllers/SeriesController.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 {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())
diff --git a/server/migrations/v2.33.0-series-audible-asin.js b/server/migrations/v2.33.0-series-audible-asin.js
new file mode 100644
index 000000000..79315a2e8
--- /dev/null
+++ b/server/migrations/v2.33.0-series-audible-asin.js
@@ -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
} - 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} - 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 }
diff --git a/server/models/Book.js b/server/models/Book.js
index 96371f3a2..849c9bc2d 100644
--- a/server/models/Book.js
+++ b/server/models/Book.js
@@ -524,8 +524,16 @@ class Book extends Model {
hasUpdates = true
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 {
- 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 })
this.series.push(series)
seriesAdded.push(series)
@@ -553,7 +561,7 @@ class Book extends Model {
*/
oldMetadataToJSON() {
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 {
title: this.title,
subtitle: this.subtitle,
diff --git a/server/models/Series.js b/server/models/Series.js
index 6ca288464..4d622a6c0 100644
--- a/server/models/Series.js
+++ b/server/models/Series.js
@@ -2,6 +2,37 @@ const { DataTypes, Model, where, fn, col, literal } = require('sequelize')
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 {
constructor(values, options) {
super(values, options)
@@ -14,6 +45,8 @@ class Series extends Model {
this.nameIgnorePrefix
/** @type {string} */
this.description
+ /** @type {string} */
+ this.audibleSeriesAsin
/** @type {UUIDV4} */
this.libraryId
/** @type {Date} */
@@ -70,15 +103,26 @@ class Series extends Model {
*
* @param {string} seriesName
* @param {string} libraryId
+ * @param {string} [asin] - Optional Audible series ASIN
* @returns {Promise}
*/
- static async findOrCreateByNameAndLibrary(seriesName, libraryId) {
+ static async findOrCreateByNameAndLibrary(seriesName, libraryId, asin = null) {
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({
name: seriesName,
nameIgnorePrefix: getTitleIgnorePrefix(seriesName),
- libraryId
+ libraryId,
+ audibleSeriesAsin: asin || null
})
}
@@ -96,7 +140,8 @@ class Series extends Model {
},
name: DataTypes.STRING,
nameIgnorePrefix: DataTypes.STRING,
- description: DataTypes.TEXT
+ description: DataTypes.TEXT,
+ audibleSeriesAsin: DataTypes.STRING
},
{
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
library.hasMany(Series, {
onDelete: 'CASCADE'
@@ -171,6 +224,7 @@ class Series extends Model {
name: this.name,
nameIgnorePrefix: getTitlePrefixAtEnd(this.name),
description: this.description,
+ audibleSeriesAsin: this.audibleSeriesAsin,
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf(),
libraryId: this.libraryId
@@ -187,3 +241,4 @@ class Series extends Model {
}
module.exports = Series
+module.exports.normalizeAudibleSeriesAsin = normalizeAudibleSeriesAsin
diff --git a/server/providers/Audible.js b/server/providers/Audible.js
index 133d3c0d8..3b25232a8 100644
--- a/server/providers/Audible.js
+++ b/server/providers/Audible.js
@@ -47,13 +47,15 @@ class Audible {
if (seriesPrimary) {
series.push({
series: seriesPrimary.name,
- sequence: this.cleanSeriesSequence(seriesPrimary.name, seriesPrimary.position || '')
+ sequence: this.cleanSeriesSequence(seriesPrimary.name, seriesPrimary.position || ''),
+ asin: seriesPrimary.asin || null
})
}
if (seriesSecondary) {
series.push({
series: seriesSecondary.name,
- sequence: this.cleanSeriesSequence(seriesSecondary.name, seriesSecondary.position || '')
+ sequence: this.cleanSeriesSequence(seriesSecondary.name, seriesSecondary.position || ''),
+ asin: seriesSecondary.asin || null
})
}
diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js
index af4405987..e4cd4514d 100644
--- a/server/scanner/Scanner.js
+++ b/server/scanner/Scanner.js
@@ -303,17 +303,35 @@ class Scanner {
Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Updated series sequence for "${existingSeries.name}" to ${seriesMatchItem.sequence} in "${libraryItem.media.title}"`)
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 {
let seriesItem = await Database.seriesModel.getByNameAndLibrary(seriesMatchItem.series, libraryItem.libraryId)
if (!seriesItem) {
seriesItem = await Database.seriesModel.create({
name: seriesMatchItem.series,
nameIgnorePrefix: getTitleIgnorePrefix(seriesMatchItem.series),
- libraryId: libraryItem.libraryId
+ libraryId: libraryItem.libraryId,
+ audibleSeriesAsin: seriesMatchItem.asin || null
})
// Update filter data
Database.addSeriesToFilterData(libraryItem.libraryId, seriesItem.name, seriesItem.id)
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({
seriesId: seriesItem.id,
diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js
index 7ae1dc866..488ac0d1a 100644
--- a/server/utils/queries/libraryItemsBookFilters.js
+++ b/server/utils/queries/libraryItemsBookFilters.js
@@ -1179,12 +1179,21 @@ module.exports = {
})
}
- // Search series
+ // Search series by name or Audible ASIN
const matchName = textSearchQuery.matchExpression('name')
const allSeries = await Database.seriesModel.findAll({
where: {
[Sequelize.Op.and]: [
- Sequelize.literal(matchName),
+ {
+ [Sequelize.Op.or]: [
+ Sequelize.literal(matchName),
+ {
+ audibleSeriesAsin: {
+ [Sequelize.Op.substring]: query
+ }
+ }
+ ]
+ },
{
libraryId: library.id
}
diff --git a/test/server/models/Series.test.js b/test/server/models/Series.test.js
new file mode 100644
index 000000000..1c276d810
--- /dev/null
+++ b/test/server/models/Series.test.js
@@ -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')
+ })
+ })
+})