Compare commits

...

7 Commits

Author SHA1 Message Date
advplyr aacdcc47ec Version bump v2.35.1 2026-05-28 15:22:55 -05:00
advplyr 499b52b4dd Update Sequelize where query for User username/email case insensitive 2026-05-28 14:49:49 -05:00
advplyr 1bad2d9072 Cleanup abmetadata file parsing & fix server crash #5268 #4287 #5142 2026-05-27 17:33:14 -05:00
advplyr c009db9f28 Merge pull request #5256 from nichwall/fix-bookauthor-collision-on-rename
Fix duplicate bookAuthor creation when renaming authors
2026-05-22 15:43:13 -05:00
advplyr 325469c5a5 Merge pull request #5255 from nichwall/refresh-token-uniqueness
Add unique UUID to access and refresh tokens
2026-05-22 15:39:01 -05:00
Nicholas Wallace c97b36e11c Add ignoreDuplicates for bookAuthor when renaming to respect unique index 2026-05-21 21:06:17 -07:00
Nicholas Wallace e944b2a2f5 Add unique UUID to access and refresh tokens 2026-05-21 17:08:39 -07:00
11 changed files with 157 additions and 50 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.35.0", "version": "2.35.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.35.0", "version": "2.35.1",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.35.0", "version": "2.35.1",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.35.0", "version": "2.35.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.35.0", "version": "2.35.1",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^0.27.2", "axios": "^0.27.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.35.0", "version": "2.35.1",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
+3
View File
@@ -1,4 +1,5 @@
const { Op } = require('sequelize') const { Op } = require('sequelize')
const uuid = require('uuid')
const Database = require('../Database') const Database = require('../Database')
const Logger = require('../Logger') const Logger = require('../Logger')
@@ -115,6 +116,7 @@ class TokenManager {
const payload = { const payload = {
userId: user.id, userId: user.id,
username: user.username, username: user.username,
jti: uuid.v4(),
type: 'access' type: 'access'
} }
const options = { const options = {
@@ -138,6 +140,7 @@ class TokenManager {
const payload = { const payload = {
userId: user.id, userId: user.id,
username: user.username, username: user.username,
jti: uuid.v4(),
type: 'refresh' type: 'refresh'
} }
const options = { const options = {
+1 -1
View File
@@ -149,7 +149,7 @@ class AuthorController {
}) })
if (libraryItems.length) { if (libraryItems.length) {
await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor
await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate) // Create all new BookAuthor await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate, { ignoreDuplicates: true }) // Create all new unique BookAuthor
for (const libraryItem of libraryItems) { for (const libraryItem of libraryItems) {
await libraryItem.saveMetadataFile() await libraryItem.saveMetadataFile()
} }
+2 -10
View File
@@ -352,11 +352,7 @@ class User extends Model {
if (cachedUser) return cachedUser if (cachedUser) return cachedUser
const user = await this.findOne({ const user = await this.findOne({
where: { where: sequelize.where(sequelize.fn('lower', sequelize.col('username')), username.toLowerCase()),
username: {
[sequelize.Op.like]: username
}
},
include: this.sequelize.models.mediaProgress include: this.sequelize.models.mediaProgress
}) })
@@ -377,11 +373,7 @@ class User extends Model {
if (cachedUser) return cachedUser if (cachedUser) return cachedUser
const user = await this.findOne({ const user = await this.findOne({
where: { where: sequelize.where(sequelize.fn('lower', sequelize.col('email')), email.toLowerCase()),
email: {
[sequelize.Op.like]: email
}
},
include: this.sequelize.models.mediaProgress include: this.sequelize.models.mediaProgress
}) })
+4 -3
View File
@@ -5,7 +5,7 @@ const { LogLevel } = require('../utils/constants')
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator') const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
class AbsMetadataFileScanner { class AbsMetadataFileScanner {
constructor() { } constructor() {}
/** /**
* Check for metadata.json file and set book metadata * Check for metadata.json file and set book metadata
@@ -32,7 +32,8 @@ class AbsMetadataFileScanner {
if (metadataText) { if (metadataText) {
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`) libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`)
const abMetadata = abmetadataGenerator.parseJson(metadataText) || {} const abMetadata = abmetadataGenerator.parseJson(metadataText, 'book') || {}
for (const key in abMetadata) { for (const key in abMetadata) {
// TODO: When to override with null or empty arrays? // TODO: When to override with null or empty arrays?
if (abMetadata[key] === undefined || abMetadata[key] === null) continue if (abMetadata[key] === undefined || abMetadata[key] === null) continue
@@ -71,7 +72,7 @@ class AbsMetadataFileScanner {
if (metadataText) { if (metadataText) {
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`) libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`)
const abMetadata = abmetadataGenerator.parseJson(metadataText) || {} const abMetadata = abmetadataGenerator.parseJson(metadataText, 'podcast') || {}
for (const key in abMetadata) { for (const key in abMetadata) {
if (abMetadata[key] === undefined || abMetadata[key] === null) continue if (abMetadata[key] === undefined || abMetadata[key] === null) continue
if (key === 'tags' && !abMetadata.tags?.length) continue if (key === 'tags' && !abMetadata.tags?.length) continue
+3
View File
@@ -825,6 +825,9 @@ class BookScanner {
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
/**
* Keys must match abmetadataGenerator.js
*/
const jsonObject = { const jsonObject = {
tags: libraryItem.media.tags || [], tags: libraryItem.media.tags || [],
chapters: libraryItem.media.chapters?.map((c) => ({ ...c })) || [], chapters: libraryItem.media.chapters?.map((c) => ({ ...c })) || [],
+3
View File
@@ -425,6 +425,9 @@ class PodcastScanner {
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
/**
* Keys must match abmetadataGenerator.js
*/
const jsonObject = { const jsonObject = {
tags: libraryItem.media.tags || [], tags: libraryItem.media.tags || [],
title: libraryItem.media.title, title: libraryItem.media.title,
+124 -19
View File
@@ -1,7 +1,51 @@
const Logger = require('../../Logger') const Logger = require('../../Logger')
const parseSeriesString = require('../parsers/parseSeriesString') const parseSeriesString = require('../parsers/parseSeriesString')
function parseJsonMetadataText(text) { const mediaTypeKeys = {
book: {
tags: 'stringArray',
title: 'string',
subtitle: 'string',
authors: 'stringArray',
narrators: 'stringArray',
series: 'stringArray',
genres: 'stringArray',
publishedYear: 'string',
publishedDate: 'string',
publisher: 'string',
description: 'string',
isbn: 'string',
asin: 'string',
language: 'string',
explicit: 'boolean',
abridged: 'boolean'
},
podcast: {
tags: 'stringArray',
title: 'string',
author: 'string',
description: 'string',
releaseDate: 'string',
genres: 'stringArray',
feedURL: 'string',
imageURL: 'string',
itunesPageURL: 'string',
itunesId: 'string',
itunesArtistId: 'string',
asin: 'string',
language: 'string',
explicit: 'boolean',
podcastType: 'string'
}
}
/**
*
* @param {string} text
* @param {"book" | "podcast"} mediaType
* @returns {Object}
*/
function parseJsonMetadataText(text, mediaType) {
try { try {
const abmetadataData = JSON.parse(text) const abmetadataData = JSON.parse(text)
@@ -19,28 +63,41 @@ function parseJsonMetadataText(text) {
} }
delete abmetadataData.metadata delete abmetadataData.metadata
if (abmetadataData.series?.length) { const expectedKeys = mediaTypeKeys[mediaType]
abmetadataData.series = [...new Set(abmetadataData.series.map((t) => t?.trim()).filter((t) => t))] if (!expectedKeys) {
abmetadataData.series = abmetadataData.series.map((series) => parseSeriesString.parse(series)) Logger.error(`[abmetadataGenerator] Invalid media type "${mediaType}"`)
return null
} }
// clean tags & remove dupes
if (abmetadataData.tags?.length) { const validated = {}
abmetadataData.tags = [...new Set(abmetadataData.tags.map((t) => t?.trim()).filter((t) => t))] for (const key in expectedKeys) {
const expectedType = expectedKeys[key]
if (!(key in abmetadataData)) continue
const validatedValue = validateMetadataValue(key, abmetadataData[key], expectedType)
if (validatedValue !== undefined) {
validated[key] = validatedValue
}
} }
if (abmetadataData.chapters?.length) {
abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.title) if (validated.series?.length) {
validated.series = validated.series.map((series) => parseSeriesString.parse(series)).filter(Boolean)
} }
// clean remove dupes
if (abmetadataData.authors?.length) { if (mediaType === 'book' && 'chapters' in abmetadataData) {
abmetadataData.authors = [...new Set(abmetadataData.authors.map((t) => t?.trim()).filter((t) => t))] if (abmetadataData.chapters === null) {
validated.chapters = []
} else if (Array.isArray(abmetadataData.chapters)) {
const cleanedChapters = cleanChaptersArray(abmetadataData.chapters, validated.title ?? abmetadataData.title)
if (cleanedChapters) {
validated.chapters = cleanedChapters
}
} else {
Logger.warn(`[abmetadataGenerator] Invalid metadata key "chapters" expected array, got ${typeof abmetadataData.chapters}`)
}
} }
if (abmetadataData.narrators?.length) {
abmetadataData.narrators = [...new Set(abmetadataData.narrators.map((t) => t?.trim()).filter((t) => t))] return validated
}
if (abmetadataData.genres?.length) {
abmetadataData.genres = [...new Set(abmetadataData.genres.map((t) => t?.trim()).filter((t) => t))]
}
return abmetadataData
} catch (error) { } catch (error) {
Logger.error(`[abmetadataGenerator] Invalid metadata.json JSON`, error) Logger.error(`[abmetadataGenerator] Invalid metadata.json JSON`, error)
return null return null
@@ -48,6 +105,54 @@ function parseJsonMetadataText(text) {
} }
module.exports.parseJson = parseJsonMetadataText module.exports.parseJson = parseJsonMetadataText
/**
* @param {string} key
* @param {*} value
* @param {string} expectedType
* @returns {*|undefined} undefined excludes the key
*/
function validateMetadataValue(key, value, expectedType) {
if (expectedType === 'string') {
if (value === null) return null
if (typeof value === 'number') return String(value)
if (typeof value === 'string') return value
Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected string, got ${typeof value}`)
return undefined
}
if (expectedType === 'boolean') {
if (value === null) return null
if (typeof value === 'boolean') return value
if (typeof value === 'string') {
const lower = value.toLowerCase()
if (lower === 'true') return true
if (lower === 'false') return false
}
Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected boolean, got ${typeof value}`)
return undefined
}
// Filter empty strings and deduplicate
if (expectedType === 'stringArray') {
if (value === null) return []
if (!Array.isArray(value)) {
Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected string array, got ${typeof value}`)
return undefined
}
const cleanedArray = value.filter((t) => typeof t === 'string')
return [...new Set(cleanedArray.map((t) => t.trim()).filter((t) => t))]
}
Logger.warn(`[abmetadataGenerator] Unknown expected type "${expectedType}" for key "${key}"`)
return undefined
}
/**
* @param {Object[]} chaptersArray
* @param {string} mediaTitle
* @returns {Object[]}
*/
function cleanChaptersArray(chaptersArray, mediaTitle) { function cleanChaptersArray(chaptersArray, mediaTitle) {
const chapters = [] const chapters = []
let index = 0 let index = 0