From 40606eb1af28852880522147c9a95f849d496592 Mon Sep 17 00:00:00 2001 From: Quentin King Date: Sat, 3 Jan 2026 10:33:56 -0600 Subject: [PATCH] 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 --- .../migrations/v2.33.0-series-audible-asin.js | 107 ++++++++++++++++++ server/models/Series.js | 63 ++++++++++- test/server/models/Series.test.js | 55 +++++++++ 3 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 server/migrations/v2.33.0-series-audible-asin.js create mode 100644 test/server/models/Series.test.js 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/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/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') + }) + }) +})