mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-09 02:41:35 +00:00
feat: add audibleSeriesAsin field to Series model
- Add audibleSeriesAsin column to Series table via migration v2.33.0 - Update Series model to include the new field - Add API endpoint for updating series ASIN (PATCH /api/series/:id) - Add unit tests for Series model
This commit is contained in:
parent
122fc34a75
commit
40606eb1af
3 changed files with 221 additions and 4 deletions
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 }
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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