mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-07 00:19:41 +00:00
Merge d5a2ea9feb into 1d0b7e383a
This commit is contained in:
commit
53106ce268
17 changed files with 592 additions and 29 deletions
|
|
@ -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())
|
||||
|
|
|
|||
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
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<Series>}
|
||||
*/
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue